Compare commits

...

38 Commits

Author SHA1 Message Date
Kit Langton 1c2b2e682b Remove covered workspace websocket todo 2026-04-30 16:46:43 -04:00
Kit Langton 4f5af93e44 Simplify SyncEvent service access 2026-04-30 16:39:15 -04:00
Kit Langton bdefdc2306 Make SyncEvent layer canonical 2026-04-30 16:28:30 -04:00
Kit Langton ec98b656fd Add SyncEvent service 2026-04-30 16:12:01 -04:00
Kit Langton 3250b814ce Fix HttpApi raw route authorization (#25154) 2026-04-30 19:55:20 +00:00
Kit Langton 0e9d9282c6 Refactor workspace service boundaries (#25152) 2026-04-30 15:34:37 -04:00
Kit Langton b315a70773 test: use Effect test helper for agent colors (#25051) 2026-04-30 15:14:25 -04:00
Kit Langton cedff6fb89 Isolate TUI thread cwd resolution test (#25147) 2026-04-30 15:10:30 -04:00
Kit Langton 87cd9446d8 test: use testEffect for plugin triggers (#25053) 2026-04-30 14:24:53 -04:00
Kit Langton f4ce240a2e Use PTY service directly in HTTP routes (#25138) 2026-04-30 14:24:43 -04:00
Kit Langton 320527a3e4 Support multiple Zed selections in TUI context (#25140) 2026-04-30 14:15:50 -04:00
Kit Langton 19271fca2d Use workspace service in HTTP routes (#25139) 2026-04-30 13:57:25 -04:00
Kit Langton feeebbe7d4 Preserve workspace context in session HTTP routes (#25136) 2026-04-30 13:53:26 -04:00
Kit Langton f384675c01 test: use Effect test helper for run-service (#25048) 2026-04-30 17:05:10 +00:00
Kit Langton ec3ab4a00c test: use testEffect for retry policy (#25050) 2026-04-30 12:55:33 -04:00
Kit Langton e4ac936eb9 test: use testEffect for plugin workspace adaptor (#25052) 2026-04-30 12:54:53 -04:00
Kit Langton 79e23b7eb9 test: use testEffect for instance state (#25115) 2026-04-30 12:53:13 -04:00
Kit Langton 92e80b4660 test: use Effect test helper for app runtime logger (#25049) 2026-04-30 12:52:29 -04:00
Kit Langton ce63ca4d7a test: use testEffect for system prompt test (#25047) 2026-04-30 12:51:32 -04:00
Kit Langton fef7981942 test: use Effect runtime in runner deadlock case (#25045) 2026-04-30 12:45:30 -04:00
Aiden Cline ffe0314c47 fix: ensure disabling OPENCODE_DISABLE_CLAUDE_CODE_SKILLS doesnt disable external skills too (#25123) 2026-04-30 11:15:53 -05:00
opencode-agent[bot] 375444a149 chore: update nix node_modules hashes 2026-04-30 15:48:28 +00:00
Kit Langton 65c15afe9f test: use testEffect for instruction tests (#25046) 2026-04-30 11:48:13 -04:00
opencode-agent[bot] 8f57a2a462 chore: generate 2026-04-30 15:46:04 +00:00
James Long 53e9cac383 refactor(core): convert control-plane workspace to Effect (#25018) 2026-04-30 11:44:58 -04:00
Sebastian fe0c182747 upgrade opentui to 0.2.0 (#24810) 2026-04-30 17:33:54 +02:00
opencode-agent[bot] 29b1060c67 chore: generate 2026-04-30 15:08:03 +00:00
Kit Langton dddfcbf0d8 test: port instance HttpApi path/vcs read coverage to Effect 2026-04-30 11:07:00 -04:00
OpeOginni 62e1335388 fix(opencode): allow oc://renderer origin in cors middleware (#25099) 2026-04-30 12:11:42 +00:00
Brendan Allan 908e28175f fix: invert *_ready getters to fix server status indicator (#25077) 2026-04-30 15:10:39 +08:00
Brendan Allan 3398fd7719 feat(httpapi): add CORS middleware to instance routes (#25074) 2026-04-30 07:06:17 +00:00
Luke Parker 9bddf7f3ef fix app crash restoring messages without model (#25062)
compliance-close / close-non-compliant (push) Has been cancelled
beta / sync (push) Has been cancelled
docs-update / update-docs (push) Has been cancelled
deploy / deploy (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-4vcpu-ubuntu-2404 target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-4vcpu-windows-2025 target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-8vcpu-ubuntu-2404-arm target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-tauri (map[host:macos-latest target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-tauri (map[host:macos-latest target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-tauri (map[host:windows-2025 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
test / unit (linux) (push) Has been cancelled
test / unit (windows) (push) Has been cancelled
test / e2e (linux) (push) Has been cancelled
test / e2e (windows) (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
stats / stats (push) Has been cancelled
daily-pr-recap / pr-recap (push) Has been cancelled
daily-issues-recap / daily-recap (push) Has been cancelled
close-issues / close (push) Has been cancelled
close-stale-prs / close-stale-prs (push) Has been cancelled
2026-04-30 14:44:53 +10:00
Dax Raad 8ba374fefa ci: enable sourcemaps for beta releases
Generate linked sourcemaps when building beta releases to help users
debug issues with readable stack traces.
2026-04-30 00:42:22 -04:00
Aiden Cline 3ef0aaf768 tweak: make azure onboarding ux a bit better (#25057) 2026-04-29 23:35:59 -05:00
Tommy D. Rossi d7701dbfb6 fix(opencode): preserve external_dir and deny parent permissions in task child sessions (#23290) 2026-04-29 22:06:29 -05:00
Kit Langton c49bf0b402 test: cover ConfigService helper (#25042) 2026-04-30 02:41:59 +00:00
Kit Langton cee9610d26 refactor: use Effect config for HttpApi authorization (#25035) 2026-04-29 22:22:32 -04:00
Kit Langton 38adc13295 test: cover HttpApi authorization middleware (#25033) 2026-04-29 21:34:52 -04:00
92 changed files with 6350 additions and 3939 deletions
+1 -1
View File
@@ -88,7 +88,7 @@ jobs:
- name: Build
id: build
run: |
./packages/opencode/script/build.ts
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
+51 -19
View File
@@ -506,8 +506,8 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.1.105",
"@opentui/solid": ">=0.1.105",
"@opentui/core": ">=0.2.0",
"@opentui/solid": ">=0.2.0",
},
"optionalPeers": [
"@opentui/core",
@@ -685,8 +685,8 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.1.105",
"@opentui/solid": "0.1.105",
"@opentui/core": "0.2.0",
"@opentui/solid": "0.2.0",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@solid-primitives/storage": "4.3.3",
@@ -1613,21 +1613,21 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="],
"@opentui/core": ["@opentui/core@0.2.0", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.2.0", "@opentui/core-darwin-x64": "0.2.0", "@opentui/core-linux-arm64": "0.2.0", "@opentui/core-linux-x64": "0.2.0", "@opentui/core-win32-arm64": "0.2.0", "@opentui/core-win32-x64": "0.2.0", "bun-webgpu": "0.1.7", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-7YOEqPUQmsgrOb9nmLEBlX8RVHPFy4HquK1C489DwfvvPTiws8nTbZ+webNQDWha7shgnYQK4Zo1EcOlpQ5+1Q=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VVmKwth3hzsQPjAZ7WGJxmzuzx0uCtynd79JJDg26D7QRM9V5beVGbKwwU5SKsDlK74EyQoY85Mv9xFY5E4jrA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eX+WNdbSNr7Bozdq/MH6p1vXIALGt0SqBHR4YtWyTh6X7KDz9FTtJT3ylxMPqiVRUGBNAiWOxoqKGXW7JLQ0TA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ARZa+ywbN/OV7esT5ZdJMlQW3a4Pr56qLlEI/X65ik88C2sgmDze4Kf2FmqtvJ1hbv1YsMfLHH9MfhLl5twyHQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjNxrD45P51cdbABoivVQLBakVYwDqAridJbHhkK6T/+EU7YsTrmAu9ae19N9ZGnrlKzLViQF8GOavNUNjAbhw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ImMjFPOWE8wcZQ2lUz1D418xonS/5EwnItUF1g5dbp1q9+A0vv2P3bxTenLwMqcYvG4wjO6gKT3n2QLnRd6qKg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6yfYHTtJ4yzbl8kXCW3Pc4eWbZDYVw21GumwdNgkjJJ2JqQAQ861em0riEoucYAa5qPYYTiMUEw7X4Fv8lGwuQ=="],
"@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="],
"@opentui/solid": ["@opentui/solid@0.2.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-kZR9i0FPAcVtomrPsKuSb+D9smooplo9zggFfU2vnnguNuQjGNbEmuJtxhCacy7ig9g3GomdNtQAzD4LiAY+3w=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2731,15 +2731,15 @@
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
"bun-webgpu": ["bun-webgpu@0.1.7", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.7", "bun-webgpu-darwin-x64": "^0.1.7", "bun-webgpu-linux-x64": "^0.1.7", "bun-webgpu-win32-x64": "^0.1.7" } }, "sha512-KUxUp+oQIf7pPBMD4Hv1TUu7DWaOZ4ciKulTk9to9+Uc8yHoYrMW7L2SJCJ4FHHkywgf/7aLRgRx0b7i6DvGIQ=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mRrFFyHzPWjsTRidAZBRcu808CPQBOUL0P6b4nxLhp+XHcV/mbUHERZMgW9s58tsojQfSdzschiQa8q+JCgRWA=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-g0NXGNgvaVCSH/jCWWlfdiquOHkbUN6vP4zqzSkIxWKQeLnqm3oADcok7SO3yIgI7v5mKpRc/ks7NDEKNH+jNQ=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-UEP7UZdEhx9otvkZczjsszL8ZVlrODANQvgl+C88/bNVmxDoFi7w1fWzGi1sZyakiETjmtFDq2/xCLhbSZxjqw=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-KZktiFkBz6sN7PEm1NVdeaLP5Q5X/PlSHZqefY4nNuWtf0LNvh54NhZe7yVv/Plz/nGbv92b0KHMBY3ki/pp6g=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -5597,8 +5597,6 @@
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -5951,6 +5949,10 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="],
"ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -6711,6 +6713,24 @@
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
"opentui-spinner/@opentui/core/bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"ora/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"ora/bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
@@ -7053,6 +7073,18 @@
"opencontrol/@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"opentui-spinner/@opentui/core/bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-h2T/LnUnISZZDn9ZQkZ/A59P+6+QdfOlrgl4RXK/vgM=",
"aarch64-linux": "sha256-+DRohG1ZEB/2LtZU90GWoqJkeyu/sW8A8oKT3f/TtQ0=",
"aarch64-darwin": "sha256-k4nsk/WduuxY8HgjRuqzGT9EjEo7V/2mAzBTYee0fZ0=",
"x86_64-darwin": "sha256-3dSvfN2+5lXwOx57x8NSIWbEZ1fp6+1T6bJpAuUNPyk="
"x86_64-linux": "sha256-cBfg4pJ4mjsfS4MFFASBaZZykArgIoeo/3woOcSGy1U=",
"aarch64-linux": "sha256-Q6cqUwfqbscdrPW0uHcfshhQINjJi0HiyURMSdOOCf4=",
"aarch64-darwin": "sha256-1AtfsD1D9YxWSEsecPJF9XsvsxsWTtVtkP5l6UW43og=",
"x86_64-darwin": "sha256-YS5/8YTf9LymAUbjXVrGDfxtKVJrpZbPnnCtsGHSHoU="
}
}
+2 -2
View File
@@ -34,8 +34,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.1.105",
"@opentui/solid": "0.1.105",
"@opentui/core": "0.2.0",
"@opentui/solid": "0.2.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -187,7 +187,7 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta,
icon: initialIcon,
get provider_ready() {
return providerQuery.isLoading
return !providerQuery.isLoading
},
provider: { all: [], connected: [], default: {} },
config: {},
@@ -207,13 +207,13 @@ export function createChildStoreManager(input: {
permission: {},
question: {},
get mcp_ready() {
return mcpQuery.isLoading
return !mcpQuery.isLoading
},
get mcp() {
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
},
get lsp_ready() {
return lspQuery.isLoading
return !lspQuery.isLoading
},
get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
+1 -1
View File
@@ -382,7 +382,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setSaved("session", session, {
agent: msg.agent,
model: msg.model,
variant: msg.model.variant ?? null,
variant: msg.model?.variant ?? null,
})
},
},
+1 -1
View File
@@ -47,7 +47,7 @@ export const Flag = {
OPENCODE_DISABLE_CLAUDE_CODE,
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
OPENCODE_DISABLE_EXTERNAL_SKILLS: OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
+21 -11
View File
@@ -4,6 +4,7 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import os from "os"
import { Context, Effect, Layer } from "effect"
import { Flock } from "./util/flock"
import { Flag } from "./flag/flag"
const app = "opencode"
const data = path.join(xdgData!, app)
@@ -47,19 +48,28 @@ export interface Interface {
readonly log: string
}
export function make(input: Partial<Interface> = {}): Interface {
return {
home: Path.home,
data: Path.data,
cache: Path.cache,
config: Flag.OPENCODE_CONFIG_DIR ?? Path.config,
state: Path.state,
bin: Path.bin,
log: Path.log,
...input,
}
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
return Service.of({
home: Path.home,
data: Path.data,
cache: Path.cache,
config: Path.config,
state: Path.state,
bin: Path.bin,
log: Path.log,
})
}),
Effect.sync(() => Service.of(make())),
)
export const layerWith = (input: Partial<Interface>) =>
Layer.effect(
Service,
Effect.sync(() => Service.of(make(input))),
)
export * as Global from "./global"
@@ -18,20 +18,17 @@ function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
const msg: Msg = JSON.parse(process.argv[2]!)
const msg: Msg = JSON.parse(process.argv[2])
const testGlobal = Layer.succeed(
Global.Service,
Global.Service.of({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
}),
)
const testGlobal = Global.layerWith({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
})
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
+9 -12
View File
@@ -93,18 +93,15 @@ async function waitForFile(file: string, timeout = 3_000) {
// Test layer
// ---------------------------------------------------------------------------
const testGlobal = Layer.succeed(
Global.Service,
Global.Service.of({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
}),
)
const testGlobal = Global.layerWith({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
})
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
+2
View File
@@ -50,6 +50,7 @@ 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 sourcemapsFlag = process.argv.includes("--sourcemaps")
const plugin = createSolidTransformPlugin()
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
@@ -199,6 +200,7 @@ for (const item of targets) {
external: ["node-gyp"],
format: "esm",
minify: true,
sourcemap: sourcemapsFlag ? "linked" : "none",
splitting: true,
compile: {
autoloadBunfig: false,
+28 -18
View File
@@ -156,28 +156,38 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
if (method.type === "api") {
if (method.authorize) {
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await put(saveProvider, {
type: "api",
key: result.key ?? key,
})
prompts.log.success("Login successful")
}
const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
if (!method.authorize) {
await put(provider, {
type: "api",
key,
...metadata,
})
prompts.outro("Done")
return true
}
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await put(saveProvider, {
type: "api",
key: result.key ?? key,
...metadata,
})
prompts.log.success("Login successful")
}
prompts.outro("Done")
return true
}
return false
@@ -12,7 +12,7 @@ import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { useEditorContext, type EditorSelection } from "@tui/context/editor"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
@@ -84,16 +84,30 @@ function fadeColor(color: RGBA, alpha: number) {
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
}
function getEditorSelectionKey(selection: EditorSelection) {
return [
selection.filePath,
selection.text,
selection.source ?? "",
selection.selection.start.line,
selection.selection.start.character,
selection.selection.end.line,
selection.selection.end.character,
].join("-")
function hasEditorRangeSelection(selection: EditorSelection["ranges"][number]) {
return (
selection.selection.start.line !== selection.selection.end.line ||
selection.selection.start.character !== selection.selection.end.character
)
}
function getEditorRangeLabel(selection: EditorSelection["ranges"][number]) {
if (!hasEditorRangeSelection(selection)) return
if (selection.selection.start.line === selection.selection.end.line) return `#${selection.selection.start.line}`
return `#${selection.selection.start.line}-${selection.selection.end.line}`
}
function formatEditorContext(selection: EditorSelection) {
const selected = selection.ranges.filter(hasEditorRangeSelection)
if (selected.length === 0)
return `<system-reminder>Note: The user opened the file "${selection.filePath}". This may or may not be relevant to the current task.</system-reminder>\n`
const ranges = selected.map((range, index) => {
const prefix = selected.length > 1 ? `Selection ${index + 1}: ` : ""
return `Note: The user selected ${prefix}${getEditorRangeLabel(range)} from "${selection.filePath}". \`\`\`${range.text}\`\`\`\n\n`
})
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
}
let stashed: { prompt: PromptInfo; cursor: number } | undefined
@@ -125,13 +139,21 @@ export function Prompt(props: PromptProps) {
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true))
const editorPath = createMemo(() => (fileContextEnabled() ? editor.selection()?.filePath : undefined))
const editorSelectionLabel = createMemo(() => {
const selection = fileContextEnabled() ? editor.selection()?.selection : undefined
const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal<string>()
const editorContext = createMemo(() => {
const selection = fileContextEnabled() ? editor.selection() : undefined
if (!selection) return
if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return
if (selection.start.line === selection.end.line) return `#${selection.start.line}`
return `#${selection.start.line}-${selection.end.line}`
return editorSelectionKey(selection) === dismissedEditorSelectionKey() ? undefined : selection
})
const editorPath = createMemo(() => editorContext()?.filePath)
const editorSelectionLabel = createMemo(() => {
const ranges = editorContext()?.ranges
if (!ranges) return
const first = ranges.find(hasEditorRangeSelection) ?? ranges[0]
if (!first) return
return [getEditorRangeLabel(first), ranges.length > 1 ? `+${ranges.length - 1}` : undefined]
.filter(Boolean)
.join(" ")
})
const editorFileLabel = createMemo(() => {
const value = editorPath()
@@ -147,6 +169,7 @@ export function Prompt(props: PromptProps) {
if (!file) return
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
})
const [editorContextHover, setEditorContextHover] = createSignal(false)
let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
@@ -163,6 +186,11 @@ export function Prompt(props: PromptProps) {
}
}
function dismissEditorContext() {
setDismissedEditorSelectionKey(editorSelectionKey(editorContext()))
editor.clearSelection()
}
const textareaKeybindings = useTextareaKeybindings()
const fileStyleId = syntax().getStyleId("extmark.file")!
@@ -292,6 +320,16 @@ export function Prompt(props: PromptProps) {
dialog.clear()
},
},
{
title: "Remove editor context",
value: "prompt.editor_context.clear",
category: "Prompt",
enabled: Boolean(editorContext()),
onSelect: (dialog) => {
dismissEditorContext()
dialog.clear()
},
},
{
title: "Paste",
value: "prompt.paste",
@@ -760,35 +798,21 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const variant = local.model.variant.current()
const editorSelection = fileContextEnabled() ? editor.selection() : undefined
const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined
const editorSelection = editorContext()
const currentEditorSelectionKey = editorSelectionKey(editorSelection)
const editorParts =
editorSelection && editorSelectionKey !== lastSubmittedEditorSelectionKey
editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey
? [
{
id: PartID.ascending(),
type: "text" as const,
text: (() => {
const start = editorSelection.selection.start
const end = editorSelection.selection.end
let text = ""
if (start.line === end.line && start.character === end.character) {
text = `Note: The user opened the file "${editorSelection.filePath}".`
} else if (start.line === end.line) {
text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
} else {
text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
}
return `<system-reminder>${text} This may or may not be relevant to the current task.</system-reminder>\n`
})(),
text: formatEditorContext(editorSelection),
synthetic: true,
metadata: {
kind: "editor_context",
source: editorSelection.source ?? "editor",
filePath: editorSelection.filePath,
selection: editorSelection.selection,
ranges: editorSelection.ranges,
},
},
]
@@ -855,7 +879,7 @@ export function Prompt(props: PromptProps) {
],
})
.catch(() => {})
lastSubmittedEditorSelectionKey = editorSelectionKey
lastSubmittedEditorSelectionKey = currentEditorSelectionKey
}
history.append({
...store.prompt,
@@ -1406,7 +1430,18 @@ export function Prompt(props: PromptProps) {
</Show>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>
<Show when={editorFileLabelDisplay()}>
{(file) => (
<text
fg={theme.secondary}
onMouseOver={() => setEditorContextHover(true)}
onMouseOut={() => setEditorContextHover(false)}
onMouseUp={dismissEditorContext}
>
{editorContextHover() ? `x ${file()}` : file()}
</text>
)}
</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
@@ -12,6 +12,9 @@ const ZedEditorRowSchema = z.object({
workspace_paths: z.string().nullable(),
timestamp: z.string(),
buffer_path: z.string().nullable(),
})
const ZedSelectionRowSchema = z.object({
selection_start: z.number().nullable(),
selection_end: z.number().nullable(),
})
@@ -24,6 +27,7 @@ const utf8 = new TextEncoder()
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
type ZedSelectionRow = z.infer<typeof ZedSelectionRowSchema>
export type ZedSelectionResult =
| { type: "selection"; selection: EditorSelection }
@@ -36,7 +40,21 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
const row = active.row
if (!row.buffer_path) return { type: "empty" }
if (row.selection_start == null || row.selection_end == null) return { type: "unavailable" }
const selections = queryZedEditorSelections(dbPath, row)
if (selections.type !== "selections") return selections
const byteRanges = selections.selections
.flatMap((selection) => {
if (selection.selection_start == null || selection.selection_end == null) return []
return [
{
start: Math.min(selection.selection_start, selection.selection_end),
end: Math.max(selection.selection_start, selection.selection_end),
},
]
})
.sort((left, right) => left.start - right.start || left.end - right.end)
if (byteRanges.length === 0) return { type: "unavailable" }
const contents = queryZedEditorContents(dbPath, row)
const text =
@@ -47,16 +65,21 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
.catch(() => undefined)
if (text == null) return { type: "unavailable" }
const startOffset = utf8ByteOffsetToStringIndex(text, Math.min(row.selection_start, row.selection_end))
const endOffset = utf8ByteOffsetToStringIndex(text, Math.max(row.selection_start, row.selection_end))
const ranges = byteRanges.map((range) => {
const startOffset = utf8ByteOffsetToStringIndex(text, range.start)
const endOffset = utf8ByteOffsetToStringIndex(text, range.end)
return {
text: text.slice(startOffset, endOffset),
selection: offsetsToSelection(text, startOffset, endOffset),
}
})
return {
type: "selection",
selection: {
text: text.slice(startOffset, endOffset),
filePath: row.buffer_path,
source: "zed",
selection: offsetsToSelection(text, startOffset, endOffset),
ranges,
},
}
}
@@ -73,14 +96,11 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
i.workspace_id as workspace_id,
w.paths as workspace_paths,
w.timestamp as timestamp,
e.buffer_path as buffer_path,
s.start as selection_start,
s.end as selection_end
e.buffer_path as buffer_path
from items i
join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id
join workspaces w on w.workspace_id = i.workspace_id
left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
left join editor_selections s on s.editor_id = e.item_id and s.workspace_id = e.workspace_id
where i.active = 1 and p.active = 1
order by w.timestamp desc`,
)
@@ -108,6 +128,34 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
}
}
function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
let db: Database | undefined
try {
db = new Database(dbPath, { readonly: true })
const raw = db
.query(
`select
start as selection_start,
end as selection_end
from editor_selections
where editor_id = $editorID and workspace_id = $workspaceID`,
)
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
const selections = raw.flatMap((selection) => {
const parsed = ZedSelectionRowSchema.safeParse(selection)
return parsed.success ? [parsed.data] : []
})
if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
return { type: "selections" as const, selections }
} catch {
return { type: "unavailable" as const }
} finally {
db?.close()
}
}
function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
let db: Database | undefined
try {
@@ -28,16 +28,46 @@ const PositionSchema = z.object({
character: z.number(),
})
const EditorSelectionSchema = z.object({
const EditorSelectionRangeSchema = z.object({
text: z.string(),
filePath: z.string(),
source: z.enum(["websocket", "zed"]).optional(),
selection: z.object({
start: PositionSchema,
end: PositionSchema,
}),
})
const EditorSelectionSchema = z
.union([
z.object({
filePath: z.string(),
source: z.enum(["websocket", "zed"]).optional(),
ranges: z.array(EditorSelectionRangeSchema).min(1),
}),
z.object({
text: z.string(),
filePath: z.string(),
source: z.enum(["websocket", "zed"]).optional(),
selection: z.object({
start: PositionSchema,
end: PositionSchema,
}),
}),
])
.transform((value) =>
"ranges" in value
? value
: {
filePath: value.filePath,
source: value.source,
ranges: [
{
text: value.text,
selection: value.selection,
},
],
},
)
const EditorMentionSchema = z.object({
filePath: z.string(),
lineStart: z.number(),
@@ -262,6 +292,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return store.selection
},
clearSelection() {
lastZedSelectionKey = undefined
setStore("selection", undefined)
},
onMention(listener: (mention: EditorMention) => void) {
@@ -352,15 +383,17 @@ function readEditorLockFile(filePath: string): EditorLockFile | undefined {
}
}
function editorSelectionKey(selection: EditorSelection | undefined) {
export function editorSelectionKey(selection: EditorSelection | undefined) {
if (!selection) return ""
return [
selection.filePath,
selection.selection.start.line,
selection.selection.start.character,
selection.selection.end.line,
selection.selection.end.character,
selection.text,
...selection.ranges.flatMap((range) => [
range.selection.start.line,
range.selection.start.character,
range.selection.end.line,
range.selection.end.character,
range.text,
]),
].join("\0")
}
+7 -4
View File
@@ -71,6 +71,12 @@ async function input(value?: string) {
return piped + "\n" + value
}
export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) {
const root = Filesystem.resolve(envPWD ?? cwd)
if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project))
return Filesystem.resolve(cwd)
}
export const TuiThreadCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
@@ -124,10 +130,7 @@ export const TuiThreadCommand = cmd({
// Resolve relative --project paths from PWD, then use the real cwd after
// chdir so the thread and worker share the same directory key.
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const next = args.project
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
: Filesystem.resolve(process.cwd())
const next = resolveThreadDirectory(args.project)
const file = await target()
try {
process.chdir(next)
@@ -1,27 +1,26 @@
import { lazy } from "@/util/lazy"
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types"
import { WorktreeAdaptor } from "./worktree"
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
const BUILTIN: Record<string, WorkspaceAdaptor> = {
worktree: WorktreeAdaptor,
}
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
export async function getAdaptor(projectID: ProjectID, type: string): Promise<WorkspaceAdaptor> {
export function getAdaptor(projectID: ProjectID, type: string): WorkspaceAdaptor {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
const builtin = BUILTIN[type]
if (builtin) return builtin()
if (builtin) return builtin
throw new Error(`Unknown workspace adaptor: ${type}`)
}
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, init]) => {
const adaptor = await init()
Object.entries(BUILTIN).map(async ([type, adaptor]) => {
return {
type,
name: adaptor.name,
@@ -1,6 +1,4 @@
import { Schema } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Worktree } from "@/worktree"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
const WorktreeConfig = Schema.Struct({
@@ -10,19 +8,26 @@ const WorktreeConfig = Schema.Struct({
})
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
async function loadWorktree() {
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
return { AppRuntime, Worktree }
}
export const WorktreeAdaptor: WorkspaceAdaptor = {
name: "Worktree",
description: "Create a git worktree",
async configure(info) {
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
const { AppRuntime, Worktree } = await loadWorktree()
const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
return {
...info,
name: worktree.name,
branch: worktree.branch,
directory: worktree.directory,
name: next.name,
branch: next.branch,
directory: next.directory,
}
},
async create(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise(
Worktree.Service.use((svc) =>
@@ -35,6 +40,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
)
},
async remove(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
},
@@ -0,0 +1,19 @@
This is a plugin to simulate a remote environment locally. Add this to `.opencode/opencode.jsonc`:
```json
"plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"],
```
In a separate terminal, run a separate OpenCode server. This will act like a remote server and the local instance will proxy all requests to it:
```
./packages/opencode/script/run-workspace-server
```
With the plugin install, you can now run OpenCode and create a `debug` workspace type. This will create a "remote" workspace which talks to the second workspace server started above.
How this works:
- The workspace server needs to know the workspace id and port to run. It waits for this information to be written to a file and starts the server when the data is written.
- The debug plugin writes this information in the `create` call to the workspace. So create a `debug` workspace will always kick off a new external server.
- The server script watches for file changes, so whenver you create a new `debug` workspace it will restart with the new information. This means that there is only ever one working `debug` workspace at a time; when you create a new one all previous sessions will show that it can't connect because previous debug workspaces do not exist.
@@ -1,66 +0,0 @@
export async function parseSSE(
body: ReadableStream<Uint8Array>,
signal: AbortSignal,
onEvent: (event: unknown) => void,
) {
const reader = body.getReader()
const decoder = new TextDecoder()
let buf = ""
let last = ""
let retry = 1000
const abort = () => {
void reader.cancel().catch(() => undefined)
}
signal.addEventListener("abort", abort)
try {
while (!signal.aborted) {
const chunk = await reader.read().catch(() => ({ done: true, value: undefined as Uint8Array | undefined }))
if (chunk.done) break
buf += decoder.decode(chunk.value, { stream: true })
buf = buf.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const chunks = buf.split("\n\n")
buf = chunks.pop() ?? ""
chunks.forEach((chunk) => {
const data: string[] = []
chunk.split("\n").forEach((line) => {
if (line.startsWith("data:")) {
data.push(line.replace(/^data:\s*/, ""))
return
}
if (line.startsWith("id:")) {
last = line.replace(/^id:\s*/, "")
return
}
if (line.startsWith("retry:")) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10)
if (!Number.isNaN(parsed)) retry = parsed
}
})
if (!data.length) return
const raw = data.join("\n")
try {
onEvent(JSON.parse(raw))
} catch {
onEvent({
type: "sse.message",
properties: {
data: raw,
id: last || undefined,
retry,
},
})
}
})
}
} finally {
signal.removeEventListener("abort", abort)
reader.releaseLock()
}
}
+8 -6
View File
@@ -1,22 +1,23 @@
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Effect } from "effect"
export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (event: GlobalEvent) => boolean }) {
if (input.signal?.aborted) return Promise.reject(input.signal.reason ?? new Error("Request aborted"))
if (input.signal?.aborted) return Effect.fail(input.signal.reason ?? new Error("Request aborted"))
return new Promise<void>((resolve, reject) => {
return Effect.callback<void, unknown>((resume) => {
const abort = () => {
cleanup()
reject(input.signal?.reason ?? new Error("Request aborted"))
resume(Effect.fail(input.signal?.reason ?? new Error("Request aborted")))
}
const handler = (event: GlobalEvent) => {
try {
if (!input.fn(event)) return
cleanup()
resolve()
resume(Effect.void)
} catch (error) {
cleanup()
reject(error)
resume(Effect.fail(error))
}
}
@@ -28,10 +29,11 @@ export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (e
const timeout = setTimeout(() => {
cleanup()
reject(new Error("Timed out waiting for global event"))
resume(Effect.fail(new Error("Timed out waiting for global event")))
}, input.timeout)
GlobalBus.on("event", handler)
input.signal?.addEventListener("abort", abort, { once: true })
return Effect.sync(cleanup)
})
}
+730 -475
View File
@@ -1,6 +1,5 @@
import { Schema } from "effect"
import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { Database } from "@/storage/db"
import { asc } from "drizzle-orm"
import { eq } from "drizzle-orm"
@@ -20,12 +19,10 @@ import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors"
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse"
import { Session } from "@/session/session"
import { SessionTable } from "@/session/session.sql"
import { SessionID } from "@/session/schema"
import { errorData } from "@/util/error"
import { AppRuntime } from "@/effect/app-runtime"
import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
import { NonNegativeInt, withStatics } from "@/util/schema"
@@ -76,6 +73,11 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
}
}
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
const log = Log.create({ service: "workspace-sync" })
export const CreateInput = Schema.Struct({
id: Schema.optional(WorkspaceID),
type: Info.fields.type,
@@ -85,286 +87,759 @@ export const CreateInput = Schema.Struct({
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
export const create = fn(CreateInput.zod, async (input) => {
const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.projectID, input.type)
const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
const info: Info = {
id,
type: config.type,
branch: config.branch ?? null,
name: config.name ?? null,
directory: config.directory ?? null,
extra: config.extra ?? null,
projectID: input.projectID,
}
Database.use((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
})
.run()
})
const env = {
OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))),
OPENCODE_WORKSPACE_ID: config.id,
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
}
await adaptor.create(config, env)
startSync(info)
await waitEvent({
timeout: TIMEOUT,
fn(event) {
if (event.workspace === info.id && event.payload.type === Event.Status.type) {
const { status } = event.payload.properties
return status === "error" || status === "connected"
}
return false
},
})
return info
})
export const SessionRestoreInput = Schema.Struct({
workspaceID: WorkspaceID,
sessionID: SessionID,
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type SessionRestoreInput = Schema.Schema.Type<typeof SessionRestoreInput>
export const sessionRestore = fn(SessionRestoreInput.zod, async (input) => {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
try {
const space = await get(input.workspaceID)
if (!space) throw new Error(`Workspace not found: ${input.workspaceID}`)
export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("WorkspaceSyncHttpError", {
message: Schema.String,
status: Schema.Number,
body: Schema.optional(Schema.String),
}) {}
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
export class WorkspaceNotFoundError extends Schema.TaggedErrorClass<WorkspaceNotFoundError>()(
"WorkspaceNotFoundError",
{
message: Schema.String,
workspaceID: WorkspaceID,
},
) {}
// Need to switch the workspace of the session
SyncEvent.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
},
})
export class SessionEventsNotFoundError extends Schema.TaggedErrorClass<SessionEventsNotFoundError>()(
"WorkspaceSessionEventsNotFoundError",
{
message: Schema.String,
sessionID: SessionID,
},
) {}
const rows = Database.use((db) =>
db
.select({
id: EventTable.id,
aggregateID: EventTable.aggregate_id,
seq: EventTable.seq,
type: EventTable.type,
data: EventTable.data,
})
.from(EventTable)
.where(eq(EventTable.aggregate_id, input.sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
if (rows.length === 0) throw new Error(`No events found for session: ${input.sessionID}`)
export class SessionRestoreHttpError extends Schema.TaggedErrorClass<SessionRestoreHttpError>()(
"WorkspaceSessionRestoreHttpError",
{
message: Schema.String,
workspaceID: WorkspaceID,
sessionID: SessionID,
status: Schema.Number,
body: Schema.String,
},
) {}
const all = rows
export class SyncTimeoutError extends Schema.TaggedErrorClass<SyncTimeoutError>()("WorkspaceSyncTimeoutError", {
message: Schema.String,
state: Schema.Record(Schema.String, Schema.Number),
}) {}
export class SyncAbortedError extends Schema.TaggedErrorClass<SyncAbortedError>()("WorkspaceSyncAbortedError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
type CreateError = Auth.AuthError
type SessionRestoreError =
| WorkspaceNotFoundError
| SessionEventsNotFoundError
| SessionRestoreHttpError
| HttpClientError.HttpClientError
type WaitForSyncError = SyncTimeoutError | SyncAbortedError
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError>
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly status: () => Effect.Effect<ConnectionStatus[]>
readonly isSyncing: (workspaceID: WorkspaceID) => Effect.Effect<boolean>
readonly waitForSync: (
workspaceID: WorkspaceID,
state: Record<string, number>,
signal?: AbortSignal,
) => Effect.Effect<void, WaitForSyncError>
readonly startWorkspaceSyncing: (projectID: ProjectID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const auth = yield* Auth.Service
const session = yield* Session.Service
const http = yield* HttpClient.HttpClient
const sync = yield* SyncEvent.Service
const connections = new Map<WorkspaceID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
const setStatus = (id: WorkspaceID, status: ConnectionStatus["status"]) => {
const prev = connections.get(id)
if (prev?.status === status) return
const next = { workspaceID: id, status }
connections.set(id, next)
const size = 10
const sets = Array.from({ length: Math.ceil(all.length / size) }, (_, i) => all.slice(i * size, (i + 1) * size))
const total = sets.length
log.info("session restore prepared", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
workspaceType: space.type,
directory: space.directory,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
events: all.length,
batches: total,
first: all[0]?.seq,
last: all.at(-1)?.seq,
})
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: 0,
},
},
})
for (const [i, events] of sets.entries()) {
log.info("session restore batch starting", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
})
if (target.type === "local") {
SyncEvent.replayAll(events)
log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
})
} else {
const url = route(target.url, "/sync/replay")
const headers = new Headers(target.headers)
headers.set("content-type", "application/json")
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
directory: space.directory ?? "",
events,
}),
})
if (!res.ok) {
const body = await res.text()
log.error("session restore batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
body,
})
throw new Error(
`Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
)
}
log.info("session restore batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
})
}
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
workspace: id,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: i + 1,
},
type: Event.Status.type,
properties: next,
},
})
}
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
const connectSSE = Effect.fn("Workspace.connectSSE")(function* (
url: URL | string,
headers: HeadersInit | undefined,
) {
const response = yield* http.execute(
HttpClientRequest.get(route(url, "/global/event"), {
headers: new Headers(headers),
accept: "text/event-stream",
}),
)
if (response.status < 200 || response.status >= 300) {
return yield* new SyncHttpError({
message: `Workspace sync HTTP failure: ${response.status}`,
status: response.status,
})
}
return response.stream
})
return {
total,
}
} catch (err) {
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
const parseSSE = Effect.fn("Workspace.parseSSE")(function* (
stream: Stream.Stream<Uint8Array, unknown>,
onEvent: (event: unknown) => Effect.Effect<void>,
) {
yield* stream.pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.mapAccum(
() => ({ data: [] as string[], id: undefined as string | undefined, retry: 1000 }),
(state, line) => {
if (line === "") {
if (!state.data.length) return [state, []]
return [{ ...state, data: [] }, [{ data: state.data.join("\n"), id: state.id, retry: state.retry }]]
}
const index = line.indexOf(":")
const field = index === -1 ? line : line.slice(0, index)
const value = index === -1 ? "" : line.slice(index + (line[index + 1] === " " ? 2 : 1))
if (field === "data") return [{ ...state, data: [...state.data, value] }, []]
if (field === "id") return [{ ...state, id: value }, []]
if (field === "retry") {
const retry = Number.parseInt(value, 10)
return [Number.isNaN(retry) ? state : { ...state, retry }, []]
}
return [state, []]
},
{
onHalt: (state) =>
state.data.length ? [{ data: state.data.join("\n"), id: state.id, retry: state.retry }] : [],
},
),
Stream.map((event) => {
try {
return JSON.parse(event.data) as unknown
} catch {
return {
type: "sse.message",
properties: {
data: event.data,
id: event.id || undefined,
retry: event.retry,
},
}
}
}),
Stream.runForEach(onEvent),
)
})
throw err
}
})
export function list(project: Project.Info) {
const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
)
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
return spaces
}
const syncHistory = Effect.fn("Workspace.syncHistory")(function* (
space: Info,
url: URL | string,
headers: HeadersInit | undefined,
) {
const sessionIDs = yield* db((db) =>
db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, space.id))
.all()
.map((row) => row.id),
)
const state = sessionIDs.length
? Object.fromEntries(
(yield* db((db) =>
db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(),
)).map((row) => [row.aggregate_id, row.seq]),
)
: {}
export const get = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
})
log.info("syncing workspace history", {
workspaceID: space.id,
sessions: sessionIDs.length,
known: Object.keys(state).length,
})
export const remove = fn(WorkspaceID.zod, async (id) => {
const sessions = Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(),
)
for (const session of sessions) {
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(session.id)))
}
const response = yield* http.execute(
HttpClientRequest.post(route(url, "/sync/history"), {
headers: new Headers(headers),
body: HttpBody.jsonUnsafe(state),
}),
)
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text
return yield* new SyncHttpError({
message: `Workspace history HTTP failure: ${response.status} ${body}`,
status: response.status,
body,
})
}
if (row) {
stopSync(id)
const events = (yield* response.json) as HistoryEvent[]
const info = fromRow(row)
try {
const adaptor = await getAdaptor(info.projectID, row.type)
await adaptor.remove(info)
} catch {
log.error("adaptor not available when removing workspace", { type: row.type })
}
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info
}
})
log.info("workspace history synced", {
workspaceID: space.id,
events: events.length,
})
yield* Effect.promise(async () => {
await WorkspaceContext.provide({
workspaceID: space.id,
async fn() {
await Effect.runPromise(
Effect.forEach(
events,
(event) =>
sync.replay(
{
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
},
{ publish: true },
),
{ discard: true },
),
)
},
})
})
})
const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) {
const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
if (target.type === "local") return
let attempt = 0
while (true) {
log.info("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting")
const stream = yield* connectSSE(target.url, target.headers).pipe(
Effect.tap(() => syncHistory(space, target.url, target.headers)),
Effect.catch((err) =>
Effect.sync(() => {
setStatus(space.id, "error")
log.info("failed to connect to global sync", {
workspace: space.name,
err,
})
return null
}),
),
)
if (stream) {
attempt = 0
log.info("global sync connected", { workspace: space.name })
setStatus(space.id, "connected")
yield* parseSSE(stream, (evt) =>
Effect.gen(function* () {
if (!evt || typeof evt !== "object" || !("payload" in evt)) return
const payload = evt.payload as { type?: string; syncEvent?: SyncEvent.SerializedEvent }
if (payload.type === "server.heartbeat") return
if (payload.type === "sync" && payload.syncEvent) {
const failed = yield* sync.replay(payload.syncEvent).pipe(
Effect.as(false),
Effect.catchCause((error) =>
Effect.sync(() => {
log.info("failed to replay global event", {
workspaceID: space.id,
error,
})
return true
}),
),
)
if (failed) return
}
try {
const event = evt as { directory?: string; project?: string; payload: unknown }
GlobalBus.emit("event", {
directory: event.directory,
project: event.project,
workspace: space.id,
payload: event.payload,
})
} catch (error) {
log.info("failed to replay global event", {
workspaceID: space.id,
error,
})
}
}),
)
log.info("disconnected from global sync: " + space.id)
setStatus(space.id, "disconnected")
}
// Back off reconnect attempts up to 2 minutes while the workspace
// stays unavailable.
yield* Effect.sleep(`${Math.min(120_000, 1_000 * 2 ** attempt)} millis`)
attempt += 1
}
})
const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) {
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
if (target.type === "local") {
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
return
}
const exists = yield* FiberMap.has(syncFibers, space.id)
if (exists && connections.get(space.id)?.status !== "error") return
setStatus(space.id, "disconnected")
yield* FiberMap.run(
syncFibers,
space.id,
// TODO: look into `tapError` to set the status but still
// allow the fiber to fail and automatically get removed
syncWorkspaceLoop(space).pipe(
Effect.catch((error) =>
Effect.sync(() => {
setStatus(space.id, "error")
log.warn("workspace listener failed", {
workspaceID: space.id,
error,
})
}),
),
),
)
})
const stopSync = Effect.fn("Workspace.stopSync")(function* (id: WorkspaceID) {
yield* FiberMap.remove(syncFibers, id)
connections.delete(id)
})
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
const id = WorkspaceID.ascending(input.id)
const adaptor = getAdaptor(input.projectID, input.type)
const config = yield* Effect.promise(() =>
Promise.resolve(adaptor.configure({ ...input, id, name: Slug.create(), directory: null })),
)
const info: Info = {
id,
type: config.type,
branch: config.branch ?? null,
name: config.name ?? null,
directory: config.directory ?? null,
extra: config.extra ?? null,
projectID: input.projectID,
}
yield* db((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
})
.run()
})
const env = {
OPENCODE_AUTH_CONTENT: JSON.stringify(yield* auth.all()),
OPENCODE_WORKSPACE_ID: config.id,
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
}
yield* Effect.promise(() => adaptor.create(config, env))
yield* Effect.all(
[
waitEvent({
timeout: TIMEOUT,
fn(event) {
if (event.workspace === info.id && event.payload.type === Event.Status.type) {
const { status } = event.payload.properties
return status === "error" || status === "connected"
}
return false
},
}),
startSync(info),
],
{ concurrency: 2, discard: true },
)
return info
})
const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) {
return yield* Effect.gen(function* () {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
const space = yield* get(input.workspaceID)
if (!space)
return yield* new WorkspaceNotFoundError({
message: `Workspace not found: ${input.workspaceID}`,
workspaceID: input.workspaceID,
})
const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
yield* sync.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
},
})
const rows = yield* db((db) =>
db
.select({
id: EventTable.id,
aggregateID: EventTable.aggregate_id,
seq: EventTable.seq,
type: EventTable.type,
data: EventTable.data,
})
.from(EventTable)
.where(eq(EventTable.aggregate_id, input.sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
if (rows.length === 0)
return yield* new SessionEventsNotFoundError({
message: `No events found for session: ${input.sessionID}`,
sessionID: input.sessionID,
})
const size = 10
// TODO: look into using effect APIs to process this in chunks
const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) =>
rows.slice(i * size, (i + 1) * size),
)
const total = sets.length
log.info("session restore prepared", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
workspaceType: space.type,
directory: space.directory,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
events: rows.length,
batches: total,
first: rows[0]?.seq,
last: rows.at(-1)?.seq,
})
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: 0,
},
},
}),
)
for (const [i, events] of sets.entries()) {
log.info("session restore batch starting", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
})
if (target.type === "local") {
yield* sync.replayAll(events)
log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
})
} else {
const url = route(target.url, "/sync/replay")
const res = yield* http.execute(
HttpClientRequest.post(url, {
headers: new Headers(target.headers),
body: HttpBody.jsonUnsafe({
directory: space.directory ?? "",
events,
}),
}),
)
if (res.status < 200 || res.status >= 300) {
const body = yield* res.text
log.error("session restore batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
body,
})
return yield* new SessionRestoreHttpError({
message: `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: res.status,
body,
})
}
log.info("session restore batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
})
}
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: i + 1,
},
},
}),
)
}
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
})
return { total }
}).pipe(
Effect.tapError((err) =>
Effect.sync(() =>
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
}),
),
),
)
})
const list = Effect.fn("Workspace.list")(function* (project: Project.Info) {
return yield* db((db) =>
db
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.project_id, project.id))
.all()
.map(fromRow)
.sort((a, b) => a.id.localeCompare(b.id)),
)
})
const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) {
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
})
const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceID) {
const sessions = yield* db((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(),
)
yield* Effect.forEach(sessions, (sessionInfo) => session.remove(sessionInfo.id), { discard: true })
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
yield* stopSync(id)
const info = fromRow(row)
yield* Effect.catch(
Effect.gen(function* () {
const adaptor = getAdaptor(info.projectID, row.type)
yield* Effect.tryPromise(() => Promise.resolve(adaptor.remove(info)))
}),
() =>
Effect.sync(() => {
log.error("adaptor not available when removing workspace", { type: row.type })
}),
)
yield* db((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info
})
const status = Effect.fn("Workspace.status")(function* () {
return [...connections.values()]
})
const isSyncing = Effect.fn("Workspace.isSyncing")(function* (workspaceID: WorkspaceID) {
const exists = yield* FiberMap.has(syncFibers, workspaceID)
return exists && connections.get(workspaceID)?.status !== "error"
})
const waitForSync = Effect.fn("Workspace.waitForSync")(function* (
workspaceID: WorkspaceID,
state: Record<string, number>,
signal?: AbortSignal,
) {
if (synced(state)) return
yield* Effect.catch(
waitEvent({
timeout: TIMEOUT,
signal,
fn(event) {
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
return false
}
return synced(state)
},
}),
(): Effect.Effect<never, WaitForSyncError> =>
signal?.aborted
? Effect.fail(
new SyncAbortedError({
message: signal.reason instanceof Error ? signal.reason.message : "Request aborted",
cause: signal.reason,
}),
)
: Effect.fail(
new SyncTimeoutError({
message: `Timed out waiting for sync fence: ${JSON.stringify(state)}`,
state,
}),
),
)
})
const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) {
// This session table join makes this query only return
// workspaces that have sessions
const rows = yield* db((db) =>
db
.selectDistinct({ workspace: WorkspaceTable })
.from(WorkspaceTable)
.innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id))
.where(eq(WorkspaceTable.project_id, projectID))
.all(),
)
for (const { workspace } of rows) {
yield* startSync(fromRow(workspace)).pipe(
Effect.catch((error) =>
Effect.sync(() => {
setStatus(workspace.id, "error")
log.warn("workspace sync failed to start", {
workspaceID: workspace.id,
error,
})
}),
),
Effect.forkDetach,
)
}
})
return Service.of({
create,
sessionRestore,
list,
get,
remove,
status,
isSyncing,
waitForSync,
startWorkspaceSyncing,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
const TIMEOUT = 5000
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"]) {
const prev = connections.get(id)
if (prev?.status === status) return
const next = { workspaceID: id, status }
connections.set(id, next)
if (status === "error") {
aborts.delete(id)
}
GlobalBus.emit("event", {
directory: "global",
workspace: id,
payload: {
type: Event.Status.type,
properties: next,
},
})
}
export function status(): ConnectionStatus[] {
return [...connections.values()]
type HistoryEvent = {
id: string
aggregate_id: string
seq: number
type: string
data: Record<string, unknown>
}
function synced(state: Record<string, number>) {
@@ -389,32 +864,6 @@ function synced(state: Record<string, number>) {
})
}
export async function isSyncing(workspaceID: WorkspaceID) {
return aborts.has(workspaceID)
}
export async function waitForSync(workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) {
if (synced(state)) return
try {
await waitEvent({
timeout: TIMEOUT,
signal,
fn(event) {
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
return false
}
return synced(state)
},
})
} catch {
if (signal?.aborted) throw signal.reason ?? new Error("Request aborted")
throw new Error(`Timed out waiting for sync fence: ${JSON.stringify(state)}`)
}
}
const log = Log.create({ service: "workspace-sync" })
function route(url: string | URL, path: string) {
const next = new URL(url)
next.pathname = `${next.pathname.replace(/\/$/, "")}${path}`
@@ -423,198 +872,4 @@ function route(url: string | URL, path: string) {
return next
}
async function connectSSE(url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) {
const res = await fetch(route(url, "/global/event"), {
method: "GET",
headers,
signal,
})
if (!res.ok) throw new Error(`Workspace sync HTTP failure: ${res.status}`)
if (!res.body) throw new Error("No response body from global sync")
return res.body
}
async function syncHistory(space: Info, url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) {
const sessionIDs = Database.use((db) =>
db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, space.id))
.all()
.map((row) => row.id),
)
const state = sessionIDs.length
? Object.fromEntries(
Database.use((db) =>
db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(),
).map((row) => [row.aggregate_id, row.seq]),
)
: {}
log.info("syncing workspace history", {
workspaceID: space.id,
sessions: sessionIDs.length,
known: Object.keys(state).length,
})
const requestHeaders = new Headers(headers)
requestHeaders.set("content-type", "application/json")
const res = await fetch(route(url, "/sync/history"), {
method: "POST",
headers: requestHeaders,
body: JSON.stringify(state),
signal,
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Workspace history HTTP failure: ${res.status} ${body}`)
}
const events = await res.json()
return WorkspaceContext.provide({
workspaceID: space.id,
fn: () => {
for (const event of events) {
SyncEvent.replay(
{
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
},
{ publish: true },
)
}
},
})
log.info("workspace history synced", {
workspaceID: space.id,
events: events.length,
})
}
async function syncWorkspaceLoop(space: Info, signal: AbortSignal) {
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") return null
let attempt = 0
while (!signal.aborted) {
log.info("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting")
let stream
try {
stream = await connectSSE(target.url, target.headers, signal)
await syncHistory(space, target.url, target.headers, signal)
} catch (err) {
stream = null
setStatus(space.id, "error")
log.info("failed to connect to global sync", {
workspace: space.name,
err,
})
}
if (stream) {
attempt = 0
log.info("global sync connected", { workspace: space.name })
setStatus(space.id, "connected")
await parseSSE(stream, signal, (evt: any) => {
try {
if (!("payload" in evt)) return
if (evt.payload.type === "server.heartbeat") return
if (evt.payload.type === "sync") {
SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent)
}
GlobalBus.emit("event", {
directory: evt.directory,
project: evt.project,
workspace: space.id,
payload: evt.payload,
})
} catch (err) {
log.info("failed to replay global event", {
workspaceID: space.id,
error: err,
})
}
})
log.info("disconnected from global sync: " + space.id)
setStatus(space.id, "disconnected")
}
// Back off reconnect attempts up to 2 minutes while the workspace
// stays unavailable.
await sleep(Math.min(120_000, 1_000 * 2 ** attempt))
attempt += 1
}
}
async function startSync(space: Info) {
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") {
void Filesystem.exists(target.directory).then((exists) => {
setStatus(space.id, exists ? "connected" : "error")
})
return
}
if (aborts.has(space.id)) return true
setStatus(space.id, "disconnected")
const abort = new AbortController()
aborts.set(space.id, abort)
void syncWorkspaceLoop(space, abort.signal).catch((error) => {
aborts.delete(space.id)
setStatus(space.id, "error")
log.warn("workspace listener failed", {
workspaceID: space.id,
error,
})
})
}
function stopSync(id: WorkspaceID) {
aborts.get(id)?.abort()
aborts.delete(id)
connections.delete(id)
}
export function startWorkspaceSyncing(projectID: ProjectID) {
const spaces = Database.use((db) =>
db
.select({ workspace: WorkspaceTable })
.from(WorkspaceTable)
.innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id))
.where(eq(WorkspaceTable.project_id, projectID))
.all(),
)
for (const row of new Map(spaces.map((row) => [row.workspace.id, row.workspace])).values()) {
void startSync(fromRow(row))
}
}
export * as Workspace from "./workspace"
@@ -41,11 +41,13 @@ import { ToolRegistry } from "@/tool/registry"
import { Format } from "@/format"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Workspace } from "@/control-plane/workspace"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
import { SyncEvent } from "@/sync"
import { Npm } from "@opencode-ai/core/npm"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
@@ -90,11 +92,13 @@ export const AppLayer = Layer.mergeAll(
Format.defaultLayer,
Project.defaultLayer,
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.defaultLayer,
Pty.defaultLayer,
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
SyncEvent.defaultLayer,
).pipe(Layer.provideMerge(Observability.layer))
const rt = ManagedRuntime.make(AppLayer, { memoMap })
@@ -0,0 +1,67 @@
import { Config, Context, Effect, Layer } from "effect"
type ConfigMap = Record<string, Config.Config<unknown>>
/**
* The service shape inferred from an object of Effect `Config` definitions.
*/
export type Shape<Fields extends ConfigMap> = {
readonly [Key in keyof Fields]: Config.Success<Fields[Key]>
}
/**
* A Context service class with generated layers for config-backed services.
*/
export type ServiceClass<Self, Id extends string, Service> = Context.ServiceClass<Self, Id, Service> & {
/** Provide already-parsed config, useful in tests. */
readonly layer: (input: Service) => Layer.Layer<Self>
/** Parse config once from the active Effect ConfigProvider and provide the service. */
readonly defaultLayer: Layer.Layer<Self, Config.ConfigError>
}
/**
* Create a Context service whose implementation is derived from Effect `Config`.
*
* This keeps Effect `Config` as the source of truth for env names, defaults, and
* validation while generating a typed service plus convenient production/test
* layers.
*
* ```ts
* class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
* "@opencode/ServerAuthConfig",
* {
* password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
* username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
* },
* ) {}
*
* const live = ServerAuthConfig.defaultLayer
* const test = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
* ```
*/
export const Service =
<Self>() =>
<const Id extends string, const Fields extends ConfigMap>(id: Id, fields: Fields) => {
class ConfigTag extends Context.Service<Self, Shape<Fields>>()(id) {
static layer(input: Shape<Fields>) {
return Layer.succeed(this, this.of(input))
}
static get defaultLayer() {
return Layer.effect(
this,
Config.all(fields)
.asEffect()
.pipe(
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the field shape, but its conditional return type also supports iterable inputs.
Effect.map((config) => this.of(config as Shape<Fields>)),
),
)
}
}
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The generated class carries typed static helpers.
return ConfigTag as ServiceClass<Self, Id, Shape<Fields>>
}
export * as ConfigService from "./config-service"
+26
View File
@@ -0,0 +1,26 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
export async function AzureAuthPlugin(_input: PluginInput): Promise<Hooks> {
const prompts = []
if (!process.env.AZURE_RESOURCE_NAME) {
prompts.push({
type: "text" as const,
key: "resourceName",
message: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
})
}
return {
auth: {
provider: "azure",
methods: [
{
type: "api",
label: "API key",
prompts,
},
],
},
}
}
+2
View File
@@ -17,6 +17,7 @@ import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { AzureAuthPlugin } from "./azure"
import { Effect, Layer, Context, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
@@ -61,6 +62,7 @@ const INTERNAL_PLUGINS: PluginInstance[] = [
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
]
function isServerPlugin(value: unknown): value is PluginInstance {
+26 -7
View File
@@ -199,12 +199,26 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
}),
azure: Effect.fnUntraced(function* (provider: Info) {
const env = yield* dep.env()
const auth = yield* dep.auth(provider.id)
const resource = iife(() => {
const name = provider.options?.resourceName
if (typeof name === "string" && name.trim() !== "") return name
return env["AZURE_RESOURCE_NAME"]
return [
provider.options?.resourceName,
auth?.type === "api" ? auth.metadata?.resourceName : undefined,
env["AZURE_RESOURCE_NAME"],
].find((name) => typeof name === "string" && name.trim() !== "")
})
if (!resource && !provider.options?.baseURL) {
return {
autoload: false,
async getModel() {
throw new Error(
"AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it",
)
},
}
}
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
@@ -215,11 +229,16 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
return sdk.responses(modelID)
}
},
options: {},
vars(_options) {
return {
...(resource && { AZURE_RESOURCE_NAME: resource }),
options: {
resourceName: resource,
},
vars(_options): Record<string, string> {
if (resource) {
return {
AZURE_RESOURCE_NAME: resource,
}
}
return {}
},
}
}),
+29 -34
View File
@@ -5,7 +5,6 @@ import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import { lazy } from "@opencode-ai/core/util/lazy"
import { Plugin } from "@/plugin"
import { Instance } from "@/project/instance"
import { Shell } from "@/shell/shell"
import type { Proc } from "#pty"
import * as Log from "@opencode-ai/core/util/log"
@@ -229,42 +228,38 @@ export const layer = Layer.effect(
subscribers: new Map(),
}
s.sessions.set(id, session)
proc.onData(
Instance.bind((chunk) => {
session.cursor += chunk.length
proc.onData((chunk) => {
session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) {
session.subscribers.delete(key)
continue
}
if (sock(ws) !== key) {
session.subscribers.delete(key)
continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(key)
}
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) {
session.subscribers.delete(key)
continue
}
if (sock(ws) !== key) {
session.subscribers.delete(key)
continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(key)
}
}
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
session.bufferCursor += excess
}),
)
proc.onExit(
Instance.bind(({ exitCode }) => {
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
bridge.fork(bus.publish(Event.Exited, { id, exitCode }))
bridge.fork(remove(id))
}),
)
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
session.bufferCursor += excess
})
proc.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
bridge.fork(bus.publish(Event.Exited, { id, exitCode }))
bridge.fork(remove(id))
})
yield* bus.publish(Event.Created, { info })
return info
})
+24
View File
@@ -0,0 +1,24 @@
import { Effect } from "effect"
const inputDecoder = new TextDecoder("utf-8", { fatal: true })
export function handlePtyInput(
handler: { onMessage: (message: string | ArrayBuffer) => void },
message: string | Uint8Array,
) {
if (typeof message === "string") {
handler.onMessage(message)
return Effect.void
}
return Effect.try({
try: () => inputDecoder.decode(message),
catch: () => new Error("invalid PTY websocket input"),
}).pipe(
Effect.catch(() => Effect.succeed(undefined)),
Effect.flatMap((decoded) => {
if (decoded === undefined) return Effect.void
handler.onMessage(decoded)
return Effect.void
}),
)
}
+12
View File
@@ -0,0 +1,12 @@
const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/
export function isAllowedCorsOrigin(input: string | undefined, opts?: { cors?: string[] }) {
if (!input) return true
if (input.startsWith("http://localhost:")) return true
if (input.startsWith("http://127.0.0.1:")) return true
if (input.startsWith("oc://renderer")) return true
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
return true
if (opencodeOrigin.test(input)) return true
return opts?.cors?.includes(input) ?? false
}
+17 -9
View File
@@ -5,6 +5,8 @@ import { EventSequenceTable } from "@/sync/event.sql"
import { Workspace } from "@/control-plane/workspace"
import type { WorkspaceID } from "@/control-plane/schema"
import * as Log from "@opencode-ai/core/util/log"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
const HEADER = "x-opencode-sync"
type State = Record<string, number>
@@ -54,16 +56,22 @@ export function parse(headers: Headers) {
) as State
}
export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
return Effect.gen(function* () {
log.info("waiting for state", {
workspaceID,
state,
})
yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))
log.info("state fully synced", {
workspaceID,
state,
})
})
}
export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
log.info("waiting for state", {
workspaceID,
state,
})
await Workspace.waitForSync(workspaceID, state, signal)
log.info("state fully synced", {
workspaceID,
state,
})
await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
}
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
+2 -9
View File
@@ -11,6 +11,7 @@ import { basicAuth } from "hono/basic-auth"
import { cors } from "hono/cors"
import { compress } from "hono/compress"
import * as ServerBackend from "./backend"
import { isAllowedCorsOrigin } from "./cors"
const log = Log.create({ service: "server" })
@@ -70,15 +71,7 @@ export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
return cors({
maxAge: 86_400,
origin(input) {
if (!input) return
if (input.startsWith("http://localhost:")) return input
if (input.startsWith("http://127.0.0.1:")) return input
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
return input
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
if (opts?.cors?.includes(input)) return input
if (isAllowedCorsOrigin(input, opts)) return input
},
})
}
+9 -9
View File
@@ -4,6 +4,7 @@ import * as Log from "@opencode-ai/core/util/log"
import * as Fence from "./fence"
import type { WorkspaceID } from "@/control-plane/schema"
import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { ProxyUtil } from "./proxy-util"
import { Effect, Stream } from "effect"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
@@ -69,18 +70,17 @@ function statusText(response: unknown) {
}
export function httpEffect(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
if (!Workspace.isSyncing(workspaceID)) {
return Effect.succeed(
new Response(`broken sync connection for workspace: ${workspaceID}`, {
return Effect.gen(function* () {
const syncing = yield* Workspace.Service.use((workspace) => workspace.isSyncing(workspaceID))
if (!syncing) {
return new Response(`broken sync connection for workspace: ${workspaceID}`, {
status: 503,
headers: {
"content-type": "text/plain; charset=utf-8",
},
}),
)
}
})
}
return Effect.gen(function* () {
const response = yield* HttpClient.execute(
HttpClientRequest.make(req.method as never)(url, {
headers: ProxyUtil.headers(req, extra),
@@ -100,7 +100,7 @@ export function httpEffect(url: string | URL, extra: HeadersInit | undefined, re
next.delete("content-encoding")
next.delete("content-length")
if (sync) yield* Effect.promise(() => Fence.wait(workspaceID, sync, req.signal))
if (sync) yield* Fence.waitEffect(workspaceID, sync, req.signal)
const body = yield* Stream.toReadableStreamEffect(response.stream.pipe(Stream.catchCause(() => Stream.empty)))
return new Response(body, {
status: response.status,
@@ -114,7 +114,7 @@ export function httpEffect(url: string | URL, extra: HeadersInit | undefined, re
}
export function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
return Effect.runPromise(httpEffect(url, extra, req, workspaceID))
return AppRuntime.runPromise(httpEffect(url, extra, req, workspaceID))
}
export function websocket(
@@ -1,8 +1,10 @@
import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod"
import { Effect } from "effect"
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import { zodObject } from "@/util/effect-zod"
import { Instance } from "@/project/instance"
@@ -62,10 +64,14 @@ export const WorkspaceRoutes = lazy(() =>
),
async (c) => {
const body = c.req.valid("json") as Omit<Workspace.CreateInput, "projectID">
const workspace = await Workspace.create({
projectID: Instance.project.id,
...body,
})
const workspace = await AppRuntime.runPromise(
Workspace.Service.use((svc) =>
svc.create({
projectID: Instance.project.id,
...body,
}),
),
)
return c.json(workspace)
},
)
@@ -87,7 +93,7 @@ export const WorkspaceRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(Workspace.list(Instance.project))
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project))))
},
)
.get(
@@ -108,8 +114,11 @@ export const WorkspaceRoutes = lazy(() =>
},
}),
async (c) => {
const ids = new Set(Workspace.list(Instance.project).map((item) => item.id))
return c.json(Workspace.status().filter((item) => ids.has(item.workspaceID)))
const result = await AppRuntime.runPromise(
Workspace.Service.use((svc) => Effect.all([svc.list(Instance.project), svc.status()])),
)
const ids = new Set(result[0].map((item) => item.id))
return c.json(result[1].filter((item) => ids.has(item.workspaceID)))
},
)
.delete(
@@ -138,7 +147,7 @@ export const WorkspaceRoutes = lazy(() =>
),
async (c) => {
const { id } = c.req.valid("param")
return c.json(await Workspace.remove(id))
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.remove(id))))
},
)
.post(
@@ -174,10 +183,14 @@ export const WorkspaceRoutes = lazy(() =>
directory: Instance.directory,
})
try {
const result = await Workspace.sessionRestore({
workspaceID: id,
...body,
})
const result = await AppRuntime.runPromise(
Workspace.Service.use((svc) =>
svc.sessionRestore({
workspaceID: id,
...body,
}),
),
)
log.info("session restore route complete", {
workspaceID: id,
sessionID: body.sessionID,
@@ -15,6 +15,7 @@ export const AddPayload = Schema.Struct({
export const StatusMap = Schema.Record(Schema.String, MCP.Status)
export const AuthStartResponse = Schema.Struct({
authorizationUrl: Schema.String,
oauthState: Schema.String,
})
export const AuthCallbackPayload = Schema.Struct({
code: Schema.String,
@@ -10,7 +10,6 @@ import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import { NonNegativeInt } from "@/util/schema"
import { Schema, SchemaGetter, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
@@ -45,7 +44,7 @@ export const UpdatePayload = Schema.Struct({
permission: Schema.optional(Permission.Ruleset),
time: Schema.optional(
Schema.Struct({
archived: Schema.optional(NonNegativeInt),
archived: Schema.optional(Session.ArchivedTimestamp),
}),
),
})
@@ -1,6 +1,6 @@
import { EffectBridge } from "@/effect/bridge"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { handlePtyInput } from "@/pty/input"
import { Shell } from "@/shell/shell"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -22,16 +22,11 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
})
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
const bridge = yield* EffectBridge.make()
return yield* Effect.promise(() =>
bridge.promise(
pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
}),
),
)
return yield* pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
})
})
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
@@ -67,54 +62,60 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
}),
)
export const ptyConnectRoute = HttpRouter.add(
"GET",
PtyPaths.connect,
export const ptyConnectRoute = HttpRouter.use((router) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const params = yield* HttpRouter.schemaPathParams(Params)
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
yield* router.add(
"GET",
PtyPaths.connect,
Effect.gen(function* () {
const params = yield* HttpRouter.schemaPathParams(Params)
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
const write = yield* socket.writer
let closed = false
const adapter = {
get readyState() {
return closed ? 3 : 1
},
send: (data: string | Uint8Array | ArrayBuffer) => {
if (closed) return
Effect.runFork(
write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)),
)
},
close: (code?: number, reason?: string) => {
if (closed) return
closed = true
Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
},
}
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) return HttpServerResponse.empty()
yield* socket
.runRaw((message) => {
handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
})
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(
Effect.sync(() => {
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
? parsedCursor
: undefined
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
const write = yield* socket.writer
const services = yield* Effect.context()
const writeScoped = (effect: Effect.Effect<void, unknown>) => {
Effect.runForkWith(services)(effect.pipe(Effect.catch(() => Effect.void)))
}
let closed = false
const adapter = {
get readyState() {
return closed ? 3 : 1
},
send: (data: string | Uint8Array | ArrayBuffer) => {
if (closed) return
writeScoped(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data))
},
close: (code?: number, reason?: string) => {
if (closed) return
closed = true
handler.onClose()
}),
),
Effect.orDie,
)
return HttpServerResponse.empty()
}).pipe(Effect.provide(Pty.defaultLayer)),
writeScoped(write(new Socket.CloseEvent(code, reason)))
},
}
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) return HttpServerResponse.empty()
yield* socket
.runRaw((message) => handlePtyInput(handler, message))
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(
Effect.sync(() => {
closed = true
handler.onClose()
}),
),
Effect.orDie,
)
return HttpServerResponse.empty()
}),
)
}),
)
@@ -1,5 +1,5 @@
import * as InstanceState from "@/effect/instance-state"
import { AppRuntime } from "@/effect/app-runtime"
import { EffectBridge } from "@/effect/bridge"
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { Command } from "@/command"
@@ -53,6 +53,13 @@ const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
const shareSvc = yield* SessionShare.Service
const promptSvc = yield* SessionPrompt.Service
const revertSvc = yield* SessionRevert.Service
const compactSvc = yield* SessionCompaction.Service
const runState = yield* SessionRunState.Service
const agentSvc = yield* Agent.Service
const permissionSvc = yield* Permission.Service
const statusSvc = yield* SessionStatus.Service
const todoSvc = yield* Todo.Service
const summary = yield* SessionSummary.Service
@@ -62,7 +69,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
return Instance.restore(instance, () =>
Array.from(
Session.list({
directory: ctx.query.directory,
directory: ctx.query.scope === "project" ? undefined : ctx.query.directory,
scope: ctx.query.scope,
path: ctx.query.path,
roots: ctx.query.roots,
@@ -148,14 +155,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})
const create = Effect.fn("SessionHttpApi.create")(function* (ctx: { payload?: Session.CreateInput }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionShare.Service.use((svc) => svc.create(ctx.payload)).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
return yield* shareSvc.create(ctx.payload)
})
const createRaw = Effect.fn("SessionHttpApi.createRaw")(function* (ctx: {
@@ -175,14 +175,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})
const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.remove(ctx.params.sessionID)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
yield* session.remove(ctx.params.sessionID)
return true
})
@@ -190,60 +183,31 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof UpdatePayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) =>
Effect.gen(function* () {
const current = yield* svc.get(ctx.params.sessionID)
if (ctx.payload.title !== undefined) {
yield* svc.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
}
if (ctx.payload.permission !== undefined) {
yield* svc.setPermission({
sessionID: ctx.params.sessionID,
permission: Permission.merge(current.permission ?? [], ctx.payload.permission),
})
}
if (ctx.payload.time?.archived !== undefined) {
yield* svc.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
}
return yield* svc.get(ctx.params.sessionID)
}),
).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
const current = yield* session.get(ctx.params.sessionID)
if (ctx.payload.title !== undefined) {
yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
}
if (ctx.payload.permission !== undefined) {
yield* session.setPermission({
sessionID: ctx.params.sessionID,
permission: Permission.merge(current.permission ?? [], ctx.payload.permission),
})
}
if (ctx.payload.time?.archived !== undefined) {
yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
}
return yield* session.get(ctx.params.sessionID)
})
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ForkPayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) =>
svc.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }),
).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
return yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID })
})
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) => svc.cancel(ctx.params.sessionID)).pipe(
Effect.provide(SessionPrompt.defaultLayer),
),
),
),
)
yield* promptSvc.cancel(ctx.params.sessionID)
return true
})
@@ -251,98 +215,45 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof InitPayload.Type
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.command({
sessionID: ctx.params.sessionID,
messageID: ctx.payload.messageID,
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
command: Command.Default.INIT,
arguments: "",
}),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
yield* promptSvc.command({
sessionID: ctx.params.sessionID,
messageID: ctx.payload.messageID,
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
command: Command.Default.INIT,
arguments: "",
})
return true
})
const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.share(ctx.params.sessionID)
return yield* session.get(ctx.params.sessionID)
}).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return yield* session.get(ctx.params.sessionID)
})
const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.unshare(ctx.params.sessionID)
return yield* session.get(ctx.params.sessionID)
}).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return yield* session.get(ctx.params.sessionID)
})
const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof SummarizePayload.Type
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const compact = yield* SessionCompaction.Service
const prompt = yield* SessionPrompt.Service
const agent = yield* Agent.Service
yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID))
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
const defaultAgent = yield* agentSvc.defaultAgent()
const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
yield* revert.cleanup(yield* session.get(ctx.params.sessionID))
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
const defaultAgent = yield* agent.defaultAgent()
const currentAgent =
messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
yield* compact.create({
sessionID: ctx.params.sessionID,
agent: currentAgent,
model: {
providerID: ctx.payload.providerID,
modelID: ctx.payload.modelID,
},
auto: ctx.payload.auto ?? false,
})
yield* prompt.loop({ sessionID: ctx.params.sessionID })
}).pipe(
Effect.provide(SessionRevert.defaultLayer),
Effect.provide(SessionCompaction.defaultLayer),
Effect.provide(SessionPrompt.defaultLayer),
Effect.provide(Agent.defaultLayer),
Effect.provide(Session.defaultLayer),
),
),
),
)
yield* compactSvc.create({
sessionID: ctx.params.sessionID,
agent: currentAgent,
model: {
providerID: ctx.payload.providerID,
modelID: ctx.payload.modelID,
},
auto: ctx.payload.auto ?? false,
})
yield* promptSvc.loop({ sessionID: ctx.params.sessionID })
return true
})
@@ -350,19 +261,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof PromptPayload.Type
}) {
const instance = yield* InstanceState.context
const bridge = yield* EffectBridge.make()
return HttpServerResponse.stream(
Stream.fromEffect(
Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.prompt({
...ctx.payload,
sessionID: ctx.params.sessionID,
} as unknown as SessionPrompt.PromptInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
bridge.promise(
promptSvc.prompt({
...ctx.payload,
sessionID: ctx.params.sessionID,
} as unknown as SessionPrompt.PromptInput),
),
),
).pipe(
@@ -377,23 +284,23 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof PromptPayload.Type
}) {
const instance = yield* InstanceState.context
const bridge = yield* EffectBridge.make()
yield* Effect.sync(() => {
Instance.restore(instance, () => {
void AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
).catch((error) => {
log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error })
void Bus.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID,
error: new NamedError.Unknown({
message: error instanceof Error ? error.message : String(error),
}).toObject(),
})
})
})
bridge.fork(
promptSvc
.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput)
.pipe(
Effect.catchCause((error) =>
Effect.sync(() => {
log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error })
void Bus.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID,
error: new NamedError.Unknown({ message: String(error) }).toObject(),
})
}),
),
),
)
})
return HttpApiSchema.NoContent.make()
})
@@ -402,111 +309,47 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof CommandPayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput)
})
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ShellPayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
return yield* promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput)
})
const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof RevertPayload.Type
}) {
const instance = yield* InstanceState.context
log.info("revert", ctx.payload)
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionRevert.Service.use((svc) => svc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })).pipe(
Effect.provide(SessionRevert.defaultLayer),
),
),
),
)
return yield* revertSvc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })
})
const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionRevert.Service.use((svc) => svc.unrevert({ sessionID: ctx.params.sessionID })).pipe(
Effect.provide(SessionRevert.defaultLayer),
),
),
),
)
return yield* revertSvc.unrevert({ sessionID: ctx.params.sessionID })
})
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
params: { permissionID: PermissionID }
payload: typeof PermissionResponsePayload.Type
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }),
).pipe(Effect.provide(Permission.defaultLayer)),
),
),
)
yield* permissionSvc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response })
return true
})
const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID }
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(ctx.params.sessionID)
yield* session.removeMessage(ctx.params)
}).pipe(Effect.provide(SessionRunState.defaultLayer), Effect.provide(Session.defaultLayer)),
),
),
)
yield* runState.assertNotBusy(ctx.params.sessionID)
yield* session.removeMessage(ctx.params)
return true
})
const deletePart = Effect.fn("SessionHttpApi.deletePart")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID; partID: PartID }
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.removePart(ctx.params)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
yield* session.removePart(ctx.params)
return true
})
@@ -524,14 +367,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
`Part mismatch: body.id='${payload.id}' vs partID='${ctx.params.partID}', body.messageID='${payload.messageID}' vs messageID='${ctx.params.messageID}', body.sessionID='${payload.sessionID}' vs sessionID='${ctx.params.sessionID}'`,
)
}
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.updatePart(payload)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
return yield* session.updatePart(payload)
})
return handlers
@@ -1,4 +1,4 @@
import { startWorkspaceSyncing } from "@/control-plane/workspace"
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Database } from "@/storage/db"
import { SyncEvent } from "@/sync"
@@ -9,15 +9,24 @@ import { eq } from "drizzle-orm"
import { lte } from "drizzle-orm"
import { not } from "drizzle-orm"
import { or } from "drizzle-orm"
import { Effect } from "effect"
import { Effect, Scope } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { HistoryPayload, ReplayPayload } from "../groups/sync"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "server.sync" })
export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const scope = yield* Scope.Scope
const sync = yield* SyncEvent.Service
const start = Effect.fn("SyncHttpApi.start")(function* () {
startWorkspaceSyncing((yield* InstanceState.context).project.id)
yield* workspace
.startWorkspaceSyncing((yield* InstanceState.context).project.id)
.pipe(Effect.ignore, Effect.forkIn(scope))
return true
})
@@ -29,8 +38,22 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
type: event.type,
data: { ...event.data },
}))
SyncEvent.replayAll(events)
return { sessionID: events[0].aggregateID }
const source = events[0].aggregateID
log.info("sync replay requested", {
sessionID: source,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
directory: ctx.payload.directory,
})
yield* sync.replayAll(events)
log.info("sync replay complete", {
sessionID: source,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
})
return { sessionID: source }
})
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
@@ -28,8 +28,8 @@ const commandAliases = {
export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
bus.publish(TuiEvent.CommandExecute, { command })
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) =>
bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type)
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
payload: typeof TuiEvent.PromptAppend.properties.Type
@@ -71,7 +71,8 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
payload: typeof CommandPayload.Type
}) {
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
// Legacy only publishes known aliases; unknown commands become undefined.
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases])
return true
})
@@ -1,58 +1,53 @@
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Instance } from "@/project/instance"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CreatePayload, SessionRestorePayload } from "../groups/workspace"
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
const instance = yield* InstanceState.context
return yield* Effect.promise(() => listAdaptors(instance.project.id))
})
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
return Workspace.list((yield* InstanceState.context).project)
return yield* workspace.list((yield* InstanceState.context).project)
})
const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.create({
...ctx.payload,
projectID: instance.project.id,
}),
),
)
return yield* workspace
.create({
...ctx.payload,
projectID: instance.project.id,
})
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
})
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
return Workspace.status().filter((item) => ids.has(item.workspaceID))
const ids = new Set((yield* workspace.list((yield* InstanceState.context).project)).map((item) => item.id))
return (yield* workspace.status()).filter((item) => ids.has(item.workspaceID))
})
const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
return yield* workspace.remove(ctx.params.id)
})
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
params: { id: Workspace.Info["id"] }
payload: typeof SessionRestorePayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.sessionRestore({
workspaceID: ctx.params.id,
sessionID: ctx.payload.sessionID,
}),
),
)
return yield* workspace
.sessionRestore({
workspaceID: ctx.params.id,
sessionID: ctx.payload.sessionID,
})
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
})
return handlers
@@ -1,32 +1,63 @@
import type { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { WorkspaceRef } from "@/effect/instance-ref"
import { Instance, type InstanceContext } from "@/project/instance"
import { Effect } from "effect"
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
const disposeAfterResponse = new WeakMap<object, InstanceContext>()
type MarkedInstance = {
ctx: InstanceContext
workspaceID?: WorkspaceID
}
export const markInstanceForDisposal = (ctx: InstanceContext) =>
HttpEffect.appendPreResponseHandler((request, response) =>
Effect.sync(() => {
disposeAfterResponse.set(request.source, ctx)
return response
// Disposal is requested by an endpoint handler, but must run from the outer
// server middleware after the response has been produced. The original Request
// object is the stable handoff key between those two phases.
const disposeAfterResponse = new WeakMap<object, MarkedInstance>()
const mark = (ctx: InstanceContext) =>
Effect.gen(function* () {
return { ctx, workspaceID: yield* WorkspaceRef }
})
// Instance.dispose/reload still publish events through legacy ALS helpers.
// Effect request handlers carry these values in services, so bridge them back
// into the legacy contexts only around the lifecycle operation.
const restoreMarked = <A>(marked: MarkedInstance, fn: () => A) =>
Effect.promise(() =>
WorkspaceContext.provide({
workspaceID: marked.workspaceID,
fn: () => Instance.restore(marked.ctx, fn),
}),
)
export const markInstanceForDisposal = (ctx: InstanceContext) =>
Effect.gen(function* () {
const marked = yield* mark(ctx)
return yield* HttpEffect.appendPreResponseHandler((request, response) =>
Effect.sync(() => {
// The response is sent before disposeMiddleware performs the teardown.
disposeAfterResponse.set(request.source, marked)
return response
}),
)
})
export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.as(
Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))),
response,
),
)
Effect.gen(function* () {
const marked = yield* mark(ctx)
return yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.as(Effect.uninterruptible(restoreMarked(marked, () => Instance.reload(next))), response),
)
})
export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
Effect.gen(function* () {
const response = yield* effect
const request = yield* HttpServerRequest.HttpServerRequest
const ctx = disposeAfterResponse.get(request.source)
if (!ctx) return response
const marked = disposeAfterResponse.get(request.source)
if (!marked) return response
disposeAfterResponse.delete(request.source)
yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose())))
yield* Effect.uninterruptible(restoreMarked(marked, () => Instance.dispose()))
return response
})
@@ -1,47 +1,63 @@
import { Effect, Encoding, Layer, Redacted, Schema } from "effect"
import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ConfigService } from "@/effect/config-service"
import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
"Unauthorized",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
const AUTH_TOKEN_QUERY = "auth_token"
const UNAUTHORIZED = 401
export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
"@opencode/ExperimentalHttpApiAuthorization",
{
error: Unauthorized,
error: HttpApiError.UnauthorizedNoContent,
security: {
basic: HttpApiSecurity.basic,
authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }),
authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }),
},
},
) {}
const emptyCredential = {
username: "",
password: Redacted.make(""),
}
export class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
"@opencode/ExperimentalHttpApiServerAuthConfig",
{
password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
},
) {}
function validateCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: { readonly username: string; readonly password: typeof emptyCredential.password },
credential: { readonly username: string; readonly password: Redacted.Redacted },
config: Context.Service.Shape<typeof ServerAuthConfig>,
) {
return Effect.gen(function* () {
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
if (credential.username !== (Flag.OPENCODE_SERVER_USERNAME ?? "opencode")) {
return yield* new Unauthorized({ message: "Unauthorized" })
}
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
return yield* new Unauthorized({ message: "Unauthorized" })
}
if (!isAuthRequired(config)) return yield* effect
if (!isCredentialAuthorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
return yield* effect
})
}
function isAuthRequired(config: Context.Service.Shape<typeof ServerAuthConfig>) {
return Option.isSome(config.password) && config.password.value !== ""
}
function isCredentialAuthorized(
credential: { readonly username: string; readonly password: Redacted.Redacted },
config: Context.Service.Shape<typeof ServerAuthConfig>,
) {
return (
Option.isSome(config.password) &&
credential.username === config.username &&
Redacted.value(credential.password) === config.password.value
)
}
function decodeCredential(input: string) {
const emptyCredential = {
username: "",
password: Redacted.make(""),
}
return Encoding.decodeBase64String(input)
.asEffect()
.pipe(
@@ -59,13 +75,54 @@ function decodeCredential(input: string) {
)
}
export const authorizationLayer = Layer.succeed(
Authorization,
Authorization.of({
basic: (effect, { credential }) => validateCredential(effect, credential),
authToken: (effect, { credential }) =>
function validateRawCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: { readonly username: string; readonly password: Redacted.Redacted },
config: Context.Service.Shape<typeof ServerAuthConfig>,
) {
if (!isAuthRequired(config)) return effect
if (!isCredentialAuthorized(credential, config))
return Effect.succeed(HttpServerResponse.empty({ status: UNAUTHORIZED }))
return effect
}
export const authorizationRouterMiddleware = HttpRouter.middleware()(
Effect.gen(function* () {
const config = yield* ServerAuthConfig
if (!isAuthRequired(config)) return (effect) => effect
return (effect) =>
Effect.gen(function* () {
return yield* validateCredential(effect, yield* decodeCredential(Redacted.value(credential)))
}),
const request = yield* HttpServerRequest.HttpServerRequest
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
if (match) {
return yield* decodeCredential(match[1]).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
}
const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY)
if (token) {
return yield* decodeCredential(token).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
}
return yield* validateRawCredential(effect, { username: "", password: Redacted.make("") }, config)
})
}),
)
export const authorizationLayer = Layer.effect(
Authorization,
Effect.gen(function* () {
const config = yield* ServerAuthConfig
return Authorization.of({
basic: (effect, { credential }) => validateCredential(effect, credential, config),
authToken: (effect, { credential }) =>
decodeCredential(Redacted.value(credential)).pipe(
Effect.flatMap((decoded) => validateCredential(effect, decoded, config)),
),
})
}),
)
@@ -75,9 +75,9 @@ function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest,
function resolveWorkspace(
id: WorkspaceID | undefined,
envWorkspaceID: WorkspaceID | undefined,
): Effect.Effect<Workspace.Info | void> {
): Effect.Effect<Workspace.Info | void, never, Workspace.Service> {
if (!id || envWorkspaceID) return Effect.void
return Effect.promise(() => Workspace.get(id))
return Workspace.Service.use((workspace) => workspace.get(id))
}
function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServerResponse {
@@ -89,7 +89,7 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe
function resolveTarget(workspace: Workspace.Info): Effect.Effect<Target> {
return Effect.gen(function* () {
const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type))
const adaptor = yield* Effect.sync(() => getAdaptor(workspace.projectID, workspace.type))
return yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace)))
})
}
@@ -99,9 +99,9 @@ function proxyRemote(
workspace: Workspace.Info,
target: RemoteTarget,
url: URL,
): Effect.Effect<HttpServerResponse.HttpServerResponse, never, Socket.WebSocketConstructor> {
): Effect.Effect<HttpServerResponse.HttpServerResponse, never, Socket.WebSocketConstructor | Workspace.Service> {
return Effect.gen(function* () {
const syncing = yield* Effect.promise(() => Workspace.isSyncing(workspace.id))
const syncing = yield* Workspace.Service.use((svc) => svc.isSyncing(workspace.id))
if (!syncing) {
return HttpServerResponse.text(`broken sync connection for workspace: ${workspace.id}`, {
status: 503,
@@ -113,10 +113,17 @@ function proxyRemote(
if (headers["upgrade"]?.toLowerCase() === "websocket") return yield* HttpApiProxy.websocket(request, proxyURL)
const response = yield* HttpApiProxy.http(proxyURL, target.headers, request)
const sync = Fence.parse(new Headers(response.headers))
if (sync)
yield* Effect.promise(() =>
Fence.wait(workspace.id, sync, request.source instanceof Request ? request.source.signal : undefined),
if (sync) {
const syncFailure = yield* Fence.waitEffect(
workspace.id,
sync,
request.source instanceof Request ? request.source.signal : undefined,
).pipe(
Effect.as(undefined),
Effect.catch((error) => Effect.succeed(HttpServerResponse.text(error.message, { status: 503 }))),
)
if (syncFailure) return syncFailure
}
return response
})
}
@@ -125,7 +132,7 @@ function planWorkspaceRequest(
request: HttpServerRequest.HttpServerRequest,
url: URL,
workspace: Workspace.Info,
): Effect.Effect<RequestPlan> {
): Effect.Effect<RequestPlan, never, Workspace.Service> {
return Effect.gen(function* () {
const target = yield* resolveTarget(workspace)
if (target.type === "remote") return RequestPlan.Remote({ request, workspace, target, url })
@@ -136,7 +143,7 @@ function planWorkspaceRequest(
function planRequest(
request: HttpServerRequest.HttpServerRequest,
sessionWorkspaceID?: WorkspaceID,
): Effect.Effect<RequestPlan> {
): Effect.Effect<RequestPlan, never, Workspace.Service> {
return Effect.gen(function* () {
const url = requestURL(request)
const envWorkspaceID = configuredWorkspaceID()
@@ -158,7 +165,7 @@ function planRequest(
function routeWorkspace<E>(
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>,
plan: RequestPlan,
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor> {
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor | Workspace.Service> {
return RequestPlan.$match(plan, {
MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)),
Remote: ({ request, workspace, target, url }) => proxyRemote(request, workspace, target, url),
@@ -167,20 +174,12 @@ function routeWorkspace<E>(
})
}
function routeWorkspaceRequest<E>(
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>,
request: HttpServerRequest.HttpServerRequest,
sessionWorkspaceID?: WorkspaceID,
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor> {
return Effect.flatMap(planRequest(request, sessionWorkspaceID), (plan) => routeWorkspace(effect, plan))
}
function routeHttpApiWorkspace<E>(
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>,
): Effect.Effect<
HttpServerResponse.HttpServerResponse,
E,
Session.Service | HttpServerRequest.HttpServerRequest | Socket.WebSocketConstructor
Session.Service | Workspace.Service | HttpServerRequest.HttpServerRequest | Socket.WebSocketConstructor
> {
return Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
@@ -188,7 +187,8 @@ function routeHttpApiWorkspace<E>(
const session = sessionID
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void))
: undefined
return yield* routeWorkspaceRequest(effect, request, session?.workspaceID)
const plan = yield* planRequest(request, session?.workspaceID)
return yield* routeWorkspace(effect, plan)
})
}
@@ -196,8 +196,12 @@ export const workspaceRoutingLayer = Layer.effect(
WorkspaceRoutingMiddleware,
Effect.gen(function* () {
const makeWebSocket = yield* Socket.WebSocketConstructor
const workspace = yield* Workspace.Service
return WorkspaceRoutingMiddleware.of((effect) =>
routeHttpApiWorkspace(effect).pipe(Effect.provideService(Socket.WebSocketConstructor, makeWebSocket)),
routeHttpApiWorkspace(effect).pipe(
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
Effect.provideService(Workspace.Service, workspace),
),
)
}),
)
@@ -205,12 +209,15 @@ export const workspaceRoutingLayer = Layer.effect(
export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: WorkspaceRouteContext }>()(
Effect.gen(function* () {
const makeWebSocket = yield* Socket.WebSocketConstructor
const workspace = yield* Workspace.Service
return (effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* routeWorkspaceRequest(effect, request).pipe(
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
)
})
const plan = yield* planRequest(request)
return yield* routeWorkspace(effect, plan)
}).pipe(
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
Effect.provideService(Workspace.Service, workspace),
)
}),
)
@@ -1,6 +1,6 @@
import { Context, Effect, Layer } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
@@ -22,17 +22,24 @@ import { Provider } from "@/provider/provider"
import { Pty } from "@/pty"
import { Question } from "@/question"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
import { SessionPrompt } from "@/session/prompt"
import { SessionRevert } from "@/session/revert"
import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { Skill } from "@/skill"
import { SyncEvent } from "@/sync"
import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Workspace } from "@/control-plane/workspace"
import { isAllowedCorsOrigin } from "@/server/cors"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { authorizationLayer } from "./middleware/authorization"
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
import { eventRoute } from "./event"
import { configHandlers } from "./handlers/config"
import { controlHandlers } from "./handlers/control"
@@ -56,7 +63,7 @@ import { disposeMiddleware } from "./lifecycle"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import * as ServerBackend from "@/server/backend"
export const context = Context.empty() as Context.Context<unknown>
export const context = Context.makeUnsafe<unknown>(new Map())
const runtime = HttpRouter.middleware()(
Effect.succeed((effect) =>
@@ -68,6 +75,14 @@ const runtime = HttpRouter.middleware()(
),
).layer
const cors = HttpRouter.middleware(
HttpMiddleware.cors({
allowedOrigins: isAllowedCorsOrigin,
maxAge: 86_400,
}),
{ global: true },
)
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers]))
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
Layer.provide([
@@ -90,14 +105,15 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(
Layer.provide(
instanceRouterMiddleware
authorizationRouterMiddleware
.combine(instanceRouterMiddleware)
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer)),
),
)
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
Layer.provide([
authorizationLayer,
authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)),
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
instanceContextLayer,
]),
@@ -105,6 +121,7 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
Layer.provide([
cors,
runtime,
Account.defaultLayer,
Agent.defaultLayer,
@@ -124,13 +141,19 @@ export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
Question.defaultLayer,
Ripgrep.defaultLayer,
Session.defaultLayer,
SessionCompaction.defaultLayer,
SessionPrompt.defaultLayer,
SessionRevert.defaultLayer,
SessionShare.defaultLayer,
SessionRunState.defaultLayer,
SessionStatus.defaultLayer,
SessionSummary.defaultLayer,
SyncEvent.defaultLayer,
Skill.defaultLayer,
Todo.defaultLayer,
ToolRegistry.defaultLayer,
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.defaultLayer,
Bus.layer,
HttpServer.layerServices,
@@ -12,7 +12,8 @@ import { eq } from "drizzle-orm"
import { EventTable } from "@/sync/event.sql"
import { lazy } from "@/util/lazy"
import * as Log from "@opencode-ai/core/util/log"
import { startWorkspaceSyncing } from "@/control-plane/workspace"
import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { Instance } from "@/project/instance"
import { errors } from "../../error"
@@ -46,7 +47,9 @@ export const SyncRoutes = lazy(() =>
},
}),
async (c) => {
startWorkspaceSyncing(Instance.project.id)
void AppRuntime.runPromise(
Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(Instance.project.id)),
)
return c.json(true)
},
)
@@ -91,7 +94,7 @@ export const SyncRoutes = lazy(() =>
last: events.at(-1)?.seq,
directory: body.directory,
})
SyncEvent.replayAll(events)
await AppRuntime.runPromise(SyncEvent.use.replayAll(events))
log.info("sync replay complete", {
sessionID: source,
+5 -2
View File
@@ -17,6 +17,7 @@ import { ServerProxy } from "./proxy"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
const RULES: Array<Rule> = [
{ path: "/experimental/workspace", action: "local" },
{ path: "/session/status", action: "forward" },
{ method: "GET", path: "/session", action: "local" },
]
@@ -71,7 +72,9 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
return next()
}
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
const workspace = await AppRuntime.runPromise(
Workspace.Service.use((svc) => svc.get(WorkspaceID.make(workspaceID))),
)
if (!workspace) {
return new Response(`Workspace not found: ${workspaceID}`, {
@@ -88,7 +91,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
return next()
}
const adaptor = await getAdaptor(workspace.projectID, workspace.type)
const adaptor = getAdaptor(workspace.projectID, workspace.type)
const target = await adaptor.target(workspace)
if (target.type === "local") {
+145 -157
View File
@@ -1,4 +1,3 @@
import os from "os"
import path from "path"
import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
@@ -8,30 +7,15 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import type { MessageV2 } from "./message-v2"
import type { MessageID } from "./schema"
const log = Log.create({ service: "instruction" })
const FILES = [
"AGENTS.md",
...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]),
"CONTEXT.md", // deprecated
]
function globalFiles() {
const files = []
if (Flag.OPENCODE_CONFIG_DIR) {
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
}
files.push(path.join(Global.Path.config, "AGENTS.md"))
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
}
return files
}
function extract(messages: MessageV2.WithParts[]) {
const paths = new Set<string>()
for (const msg of messages) {
@@ -63,176 +47,180 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> =
Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const fs = yield* AppFileSystem.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient
> = Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const fs = yield* AppFileSystem.Service
const global = yield* Global.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const globalFiles = [
path.join(global.config, "AGENTS.md"),
...(!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
]
const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() =>
Effect.succeed({
// Track which instruction files have already been attached for a given assistant message.
claims: new Map<MessageID, Set<string>>(),
}),
),
)
const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() =>
Effect.succeed({
// Track which instruction files have already been attached for a given assistant message.
claims: new Map<MessageID, Set<string>>(),
}),
),
)
const relative = Effect.fnUntraced(function* (instruction: string) {
const ctx = yield* InstanceState.context
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return yield* fs
.globUp(instruction, ctx.directory, ctx.worktree)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
}
if (!Flag.OPENCODE_CONFIG_DIR) {
log.warn(
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
)
return []
}
const relative = Effect.fnUntraced(function* (instruction: string) {
const ctx = yield* InstanceState.context
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return yield* fs
.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR)
.globUp(instruction, ctx.directory, ctx.worktree)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
})
}
return yield* fs
.globUp(instruction, global.config, global.config)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
})
const read = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
})
const read = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
})
const fetch = Effect.fnUntraced(function* (url: string) {
const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
Effect.timeout(5000),
Effect.catch(() => Effect.succeed(null)),
)
if (!res) return ""
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
return new TextDecoder().decode(body)
})
const fetch = Effect.fnUntraced(function* (url: string) {
const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
Effect.timeout(5000),
Effect.catch(() => Effect.succeed(null)),
)
if (!res) return ""
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
return new TextDecoder().decode(body)
})
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
const s = yield* InstanceState.get(state)
s.claims.delete(messageID)
})
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
const s = yield* InstanceState.get(state)
s.claims.delete(messageID)
})
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
const config = yield* cfg.get()
const ctx = yield* InstanceState.context
const paths = new Set<string>()
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
const config = yield* cfg.get()
const ctx = yield* InstanceState.context
const paths = new Set<string>()
for (const file of globalFiles()) {
if (yield* fs.existsSafe(file)) {
paths.add(path.resolve(file))
for (const file of globalFiles) {
if (yield* fs.existsSafe(file)) {
paths.add(path.resolve(file))
break
}
}
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree)
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
break
}
}
}
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree)
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
break
}
}
if (config.instructions) {
for (const raw of config.instructions) {
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
const instruction = raw.startsWith("~/") ? path.join(global.home, raw.slice(2)) : raw
const matches = yield* (
path.isAbsolute(instruction)
? fs.glob(path.basename(instruction), {
cwd: path.dirname(instruction),
absolute: true,
include: "file",
})
: relative(instruction)
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
matches.forEach((item) => paths.add(path.resolve(item)))
}
}
if (config.instructions) {
for (const raw of config.instructions) {
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw
const matches = yield* (
path.isAbsolute(instruction)
? fs.glob(path.basename(instruction), {
cwd: path.dirname(instruction),
absolute: true,
include: "file",
})
: relative(instruction)
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
matches.forEach((item) => paths.add(path.resolve(item)))
}
}
return paths
})
return paths
})
const system = Effect.fn("Instruction.system")(function* () {
const config = yield* cfg.get()
const paths = yield* systemPaths()
const urls = (config.instructions ?? []).filter(
(item) => item.startsWith("https://") || item.startsWith("http://"),
)
const system = Effect.fn("Instruction.system")(function* () {
const config = yield* cfg.get()
const paths = yield* systemPaths()
const urls = (config.instructions ?? []).filter(
(item) => item.startsWith("https://") || item.startsWith("http://"),
)
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
return [
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
]
})
return [
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
]
})
const find = Effect.fn("Instruction.find")(function* (dir: string) {
for (const file of FILES) {
const filepath = path.resolve(path.join(dir, file))
if (yield* fs.existsSafe(filepath)) return filepath
}
return undefined
})
const find = Effect.fn("Instruction.find")(function* (dir: string) {
for (const file of FILES) {
const filepath = path.resolve(path.join(dir, file))
if (yield* fs.existsSafe(filepath)) return filepath
}
})
const resolve = Effect.fn("Instruction.resolve")(function* (
messages: MessageV2.WithParts[],
filepath: string,
messageID: MessageID,
) {
const sys = yield* systemPaths()
const already = extract(messages)
const results: { filepath: string; content: string }[] = []
const s = yield* InstanceState.get(state)
const root = path.resolve(yield* InstanceState.directory)
const resolve = Effect.fn("Instruction.resolve")(function* (
messages: MessageV2.WithParts[],
filepath: string,
messageID: MessageID,
) {
const sys = yield* systemPaths()
const already = extract(messages)
const results: { filepath: string; content: string }[] = []
const s = yield* InstanceState.get(state)
const root = path.resolve(yield* InstanceState.directory)
const target = path.resolve(filepath)
let current = path.dirname(target)
// Walk upward from the file being read and attach nearby instruction files once per message.
while (current.startsWith(root) && current !== root) {
const found = yield* find(current)
if (!found || found === target || sys.has(found) || already.has(found)) {
current = path.dirname(current)
continue
}
let set = s.claims.get(messageID)
if (!set) {
set = new Set()
s.claims.set(messageID, set)
}
if (set.has(found)) {
current = path.dirname(current)
continue
}
set.add(found)
const content = yield* read(found)
if (content) {
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
}
const target = path.resolve(filepath)
let current = path.dirname(target)
// Walk upward from the file being read and attach nearby instruction files once per message.
while (current.startsWith(root) && current !== root) {
const found = yield* find(current)
if (!found || found === target || sys.has(found) || already.has(found)) {
current = path.dirname(current)
continue
}
return results
})
let set = s.claims.get(messageID)
if (!set) {
set = new Set()
s.claims.set(messageID, set)
}
if (set.has(found)) {
current = path.dirname(current)
continue
}
return Service.of({ clear, systemPaths, system, find, resolve })
}),
)
set.add(found)
const content = yield* read(found)
if (content) {
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
}
current = path.dirname(current)
}
return results
})
return Service.of({ clear, systemPaths, system, find, resolve })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Global.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
+4 -2
View File
@@ -38,6 +38,7 @@ export const layer = Layer.effect(
const bus = yield* Bus.Service
const summary = yield* SessionSummary.Service
const state = yield* SessionRunState.Service
const sync = yield* SyncEvent.Service
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
yield* state.assertNotBusy(input.sessionID)
@@ -121,7 +122,7 @@ export const layer = Layer.effect(
remove.push(msg)
}
for (const msg of remove) {
SyncEvent.run(MessageV2.Event.Removed, {
yield* sync.run(MessageV2.Event.Removed, {
sessionID,
messageID: msg.info.id,
})
@@ -133,7 +134,7 @@ export const layer = Layer.effect(
const removeParts = target.parts.slice(idx)
target.parts = target.parts.slice(0, idx)
for (const part of removeParts) {
SyncEvent.run(MessageV2.Event.PartRemoved, {
yield* sync.run(MessageV2.Event.PartRemoved, {
sessionID,
messageID: target.info.id,
partID: part.id,
@@ -156,6 +157,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
),
)
+34 -34
View File
@@ -142,11 +142,15 @@ const Share = Schema.Struct({
url: Schema.String,
})
// Legacy HTTP accepted any number here, and persisted data may already contain
// negative values. Keep archive timestamps permissive while other clocks stay non-negative.
export const ArchivedTimestamp = Schema.Number
const Time = Schema.Struct({
created: NonNegativeInt,
updated: NonNegativeInt,
compacting: optionalOmitUndefined(NonNegativeInt),
archived: optionalOmitUndefined(NonNegativeInt),
archived: optionalOmitUndefined(ArchivedTimestamp),
})
const Revert = Schema.Struct({
@@ -215,7 +219,7 @@ export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema
)
export const SetArchivedInput = Schema.Struct({
sessionID: SessionID,
time: Schema.optional(NonNegativeInt),
time: Schema.optional(ArchivedTimestamp),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const SetPermissionInput = Schema.Struct({
sessionID: SessionID,
@@ -244,7 +248,7 @@ const UpdatedTime = Schema.Struct({
created: Schema.optional(Schema.NullOr(NonNegativeInt)),
updated: Schema.optional(Schema.NullOr(NonNegativeInt)),
compacting: Schema.optional(Schema.NullOr(NonNegativeInt)),
archived: Schema.optional(Schema.NullOr(NonNegativeInt)),
archived: Schema.optional(Schema.NullOr(ArchivedTimestamp)),
})
const UpdatedInfo = Schema.Struct({
@@ -439,11 +443,12 @@ export type Patch = Types.DeepMutable<SyncEvent.Event<typeof Event.Updated>["dat
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> = Layer.effect(
export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service | SyncEvent.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const storage = yield* Storage.Service
const sync = yield* SyncEvent.Service
const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID
@@ -473,7 +478,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
}
log.info("created", result)
yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result }))
yield* sync.run(Event.Created, { sessionID: result.id, info: result })
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// This only exist for backwards compatibility. We should not be
@@ -521,10 +526,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
Effect.catchCause(() => Effect.succeed(false)),
)
yield* Effect.sync(() => {
SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance })
SyncEvent.remove(sessionID)
})
yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance })
yield* sync.remove(sessionID)
} catch (e) {
log.error(e)
}
@@ -532,19 +535,17 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
const updateMessage = <T extends MessageV2.Info>(msg: T): Effect.Effect<T> =>
Effect.gen(function* () {
yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }))
yield* sync.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg })
return msg
}).pipe(Effect.withSpan("Session.updateMessage"))
const updatePart = <T extends MessageV2.Part>(part: T): Effect.Effect<T> =>
Effect.gen(function* () {
yield* Effect.sync(() =>
SyncEvent.run(MessageV2.Event.PartUpdated, {
sessionID: part.sessionID,
part: structuredClone(part),
time: Date.now(),
}),
)
yield* sync.run(MessageV2.Event.PartUpdated, {
sessionID: part.sessionID,
part: structuredClone(part),
time: Date.now(),
})
return part
}).pipe(Effect.withSpan("Session.updatePart"))
@@ -585,7 +586,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
path: sessionPath(ctx.worktree, ctx.directory),
title: input?.title,
permission: input?.permission,
workspaceID: workspace,
workspaceID: input?.workspaceID ?? workspace,
})
})
@@ -631,8 +632,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
return session
})
const patch = (sessionID: SessionID, info: Patch) =>
Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID, info }))
const patch = (sessionID: SessionID, info: Patch) => sync.run(Event.Updated, { sessionID, info })
const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) {
yield* patch(sessionID, { time: { updated: Date.now() } })
@@ -689,12 +689,10 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
sessionID: SessionID
messageID: MessageID
}) {
yield* Effect.sync(() =>
SyncEvent.run(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: input.messageID,
}),
)
yield* sync.run(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: input.messageID,
})
return input.messageID
})
@@ -703,13 +701,11 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
messageID: MessageID
partID: PartID
}) {
yield* Effect.sync(() =>
SyncEvent.run(MessageV2.Event.PartRemoved, {
sessionID: input.sessionID,
messageID: input.messageID,
partID: input.partID,
}),
)
yield* sync.run(MessageV2.Event.PartRemoved, {
sessionID: input.sessionID,
messageID: input.messageID,
partID: input.partID,
})
return input.partID
})
@@ -760,7 +756,11 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Storage.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
)
export function* list(input?: {
directory?: string
+4 -4
View File
@@ -21,20 +21,19 @@ export const layer = Layer.effect(
const session = yield* Session.Service
const shareNext = yield* ShareNext.Service
const scope = yield* Scope.Scope
const sync = yield* SyncEvent.Service
const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) {
const conf = yield* cfg.get()
if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration")
const result = yield* shareNext.create(sessionID)
yield* Effect.sync(() =>
SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }),
)
yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } })
return result
})
const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) {
yield* shareNext.remove(sessionID)
yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } }))
yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })
})
const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) {
@@ -54,6 +53,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(ShareNext.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
)
export * as SessionShare from "./session"
+15 -8
View File
@@ -1,4 +1,3 @@
import os from "os"
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
@@ -20,7 +19,8 @@ import * as Log from "@opencode-ai/core/util/log"
import { Discovery } from "./discovery"
const log = Log.create({ service: "skill" })
const EXTERNAL_DIRS = [".claude", ".agents"]
const CLAUDE_EXTERNAL_DIR = ".claude"
const AGENTS_EXTERNAL_DIR = ".agents"
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
const SKILL_PATTERN = "**/SKILL.md"
@@ -147,20 +147,25 @@ const discoverSkills = Effect.fnUntraced(function* (
config: Config.Interface,
discovery: Discovery.Interface,
fsys: AppFileSystem.Interface,
global: Global.Interface,
directory: string,
worktree: string,
) {
const state: ScanState = { matches: new Set(), dirs: new Set() }
const externalDirs: string[] = []
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir)
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) externalDirs.push(CLAUDE_EXTERNAL_DIR)
externalDirs.push(AGENTS_EXTERNAL_DIR)
for (const dir of externalDirs) {
const root = path.join(global.home, dir)
if (!(yield* fsys.isDir(root))) continue
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
}
const upDirs = yield* fsys
.up({ targets: EXTERNAL_DIRS, start: directory, stop: worktree })
.up({ targets: externalDirs, start: directory, stop: worktree })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const root of upDirs) {
@@ -175,7 +180,7 @@ const discoverSkills = Effect.fnUntraced(function* (
const cfg = yield* config.get()
for (const item of cfg.skills?.paths ?? []) {
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
if (!(yield* fsys.isDir(dir))) {
log.warn("skill path not found", { path: dir })
@@ -216,13 +221,14 @@ export const layer = Layer.effect(
const config = yield* Config.Service
const bus = yield* Bus.Service
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const discovered = yield* InstanceState.make(
Effect.fn("Skill.discovery")(function* (ctx) {
return yield* discoverSkills(config, discovery, fsys, ctx.directory, ctx.worktree)
return yield* discoverSkills(config, discovery, fsys, global, ctx.directory, ctx.worktree)
}),
)
const state = yield* InstanceState.make(
Effect.fn("Skill.state")(function* (ctx) {
Effect.fn("Skill.state")(function* () {
const s: State = { skills: {}, dirs: new Set() }
yield* loadSkills(s, yield* InstanceState.get(discovered), bus)
return s
@@ -259,6 +265,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.layer),
)
export function fmt(list: Info[], opts: { verbose: boolean }) {
+126 -78
View File
@@ -9,9 +9,11 @@ import { EventSequenceTable, EventTable } from "./event.sql"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { EventID } from "./schema"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Schema as EffectSchema } from "effect"
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
import { zodObject } from "@/util/effect-zod"
import type { DeepMutable } from "@/util/schema"
import { makeRuntime } from "@/effect/run-service"
import { serviceUse } from "@/effect/service-use"
// Keep `Event["data"]` mutable because projectors mutate the persisted shape
// when writing to the database. Bus payloads (`Properties`) stay readonly —
@@ -46,6 +48,125 @@ export type SerializedEvent<Def extends Definition = Definition> = Event<Def> &
type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
export interface Interface {
readonly run: <Def extends Definition>(
def: Def,
data: Event<Def>["data"],
options?: { publish?: boolean },
) => Effect.Effect<void>
readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect<void>
readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect<string | undefined>
readonly remove: (aggregateID: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SyncEvent") {}
export const layer = Layer.effect(Service)(
Effect.gen(function* () {
const replay: Interface["replay"] = Effect.fn("SyncEvent.replay")(function* (event, options) {
const def = registry.get(event.type)
if (!def) {
throw new Error(`Unknown event type: ${event.type}`)
}
const row = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
.get(),
)
const latest = row?.seq ?? -1
if (event.seq <= latest) return
const expected = latest + 1
if (event.seq !== expected) {
throw new Error(
`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`,
)
}
process(def, event, { publish: !!options?.publish })
})
const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) {
const source = events[0]?.aggregateID
if (!source) return undefined
if (events.some((item) => item.aggregateID !== source)) {
throw new Error("Replay events must belong to the same session")
}
const start = events[0].seq
for (const [i, item] of events.entries()) {
const seq = start + i
if (item.seq !== seq) {
throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`)
}
}
for (const item of events) {
yield* replay(item, options)
}
return source
})
const run: Interface["run"] = Effect.fn("SyncEvent.run")(function* (def, data, options) {
const agg = (data as Record<string, string>)[def.aggregate]
// This should never happen: we've enforced it via typescript in
// the definition
if (agg == null) {
throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`)
}
if (def.version !== versions.get(def.type)) {
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
}
const { publish = true } = options || {}
// Note that this is an "immediate" transaction which is critical.
// We need to make sure we can safely read and write with nothing
// else changing the data from under us
Database.transaction(
(tx) => {
const id = EventID.ascending()
const row = tx
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, agg))
.get()
const seq = row?.seq != null ? row.seq + 1 : 0
const event = { id, seq, aggregateID: agg, data }
process(def, event, { publish })
},
{
behavior: "immediate",
},
)
})
const remove: Interface["remove"] = Effect.fn("SyncEvent.remove")(function* (aggregateID) {
Database.transaction((tx) => {
tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
})
})
return Service.of({
run,
replay,
replayAll,
remove,
})
}),
)
export const defaultLayer = layer
export const use = serviceUse(Service)
const runtime = makeRuntime(Service, defaultLayer)
export const registry = new Map<string, Definition>()
let projectors: Map<Definition, ProjectorFunc> | undefined
const versions = new Map<string, number>()
@@ -186,92 +307,19 @@ function process<Def extends Definition>(def: Def, event: Event<Def>, options: {
}
export function replay(event: SerializedEvent, options?: { publish: boolean }) {
const def = registry.get(event.type)
if (!def) {
throw new Error(`Unknown event type: ${event.type}`)
}
const row = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
.get(),
)
const latest = row?.seq ?? -1
if (event.seq <= latest) {
return
}
const expected = latest + 1
if (event.seq !== expected) {
throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
}
process(def, event, { publish: !!options?.publish })
return runtime.runSync((sync) => sync.replay(event, options))
}
export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) {
const source = events[0]?.aggregateID
if (!source) return
if (events.some((item) => item.aggregateID !== source)) {
throw new Error("Replay events must belong to the same session")
}
const start = events[0].seq
for (const [i, item] of events.entries()) {
const seq = start + i
if (item.seq !== seq) {
throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`)
}
}
for (const item of events) {
replay(item, options)
}
return source
return runtime.runSync((sync) => sync.replayAll(events, options))
}
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) {
const agg = (data as Record<string, string>)[def.aggregate]
// This should never happen: we've enforced it via typescript in
// the definition
if (agg == null) {
throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`)
}
if (def.version !== versions.get(def.type)) {
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
}
const { publish = true } = options || {}
// Note that this is an "immediate" transaction which is critical.
// We need to make sure we can safely read and write with nothing
// else changing the data from under us
Database.transaction(
(tx) => {
const id = EventID.ascending()
const row = tx
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, agg))
.get()
const seq = row?.seq != null ? row.seq + 1 : 0
const event = { id, seq, aggregateID: agg, data }
process(def, event, { publish })
},
{
behavior: "immediate",
},
)
return runtime.runSync((sync) => sync.run(def, data, options))
}
export function remove(aggregateID: string) {
Database.transaction((tx) => {
tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
})
return runtime.runSync((sync) => sync.remove(aggregateID))
}
export function payloads() {
+4
View File
@@ -64,12 +64,16 @@ export const TaskTool = Tool.define(
const session = taskID
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
: undefined
const parent = yield* sessions.get(ctx.sessionID)
const nextSession =
session ??
(yield* sessions.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...(parent.permission ?? []).filter(
(rule) => rule.permission === "external_directory" || rule.action === "deny",
),
...(canTodo
? []
: [
@@ -10,6 +10,7 @@ type ZedFixtureOptions = {
editor?: boolean
selectionStart?: number | null
selectionEnd?: number | null
selections?: Array<{ start: number | null; end: number | null }>
contents?: string
}
@@ -30,10 +31,16 @@ async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) {
db.run("insert into items values (1, 1, 1, 1, ?)", [options.itemKind ?? "Editor"])
if (options.editor !== false) {
db.run("insert into editors values (1, 1, ?, ?)", [filePath, contents])
db.run("insert into editor_selections values (1, 1, ?, ?)", [
options.selectionStart === undefined ? 4 : options.selectionStart,
options.selectionEnd === undefined ? 7 : options.selectionEnd,
])
;(
options.selections ?? [
{
start: options.selectionStart === undefined ? 4 : options.selectionStart,
end: options.selectionEnd === undefined ? 7 : options.selectionEnd,
},
]
).forEach((selection) =>
db.run("insert into editor_selections values (1, 1, ?, ?)", [selection.start, selection.end]),
)
}
db.close()
@@ -66,13 +73,59 @@ test("resolveZedSelection returns active editor selection", async () => {
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
text: "two",
filePath: fixture.filePath,
source: "zed",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 4 },
ranges: [
{
text: "two",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 4 },
},
},
],
},
})
})
test("resolveZedSelection returns all active editor selections sorted by offset", async () => {
await using tmp = await tmpdir()
const contents = "one\ntwo\nthree\nfour"
const fixture = await writeZedFixture(tmp.path, {
contents,
selections: [
{
start: utf8ByteOffset(contents, contents.indexOf("four")),
end: utf8ByteOffset(contents, contents.indexOf("four") + 4),
},
{
start: utf8ByteOffset(contents, contents.indexOf("two")),
end: utf8ByteOffset(contents, contents.indexOf("two") + 3),
},
],
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "two",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 4 },
},
},
{
text: "four",
selection: {
start: { line: 4, character: 1 },
end: { line: 4, character: 5 },
},
},
],
},
})
})
@@ -90,13 +143,17 @@ test("resolveZedSelection converts Zed UTF-8 byte offsets to string offsets", as
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
text: "TARGET",
filePath: fixture.filePath,
source: "zed",
selection: {
start: { line: 4, character: 1 },
end: { line: 4, character: 7 },
},
ranges: [
{
text: "TARGET",
selection: {
start: { line: 4, character: 1 },
end: { line: 4, character: 7 },
},
},
],
},
})
})
@@ -114,13 +171,17 @@ test("resolveZedSelection handles non-ASCII text inside the selected range", asy
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
text: "выбор",
filePath: fixture.filePath,
source: "zed",
selection: {
start: { line: 3, character: 1 },
end: { line: 3, character: 6 },
},
ranges: [
{
text: "выбор",
selection: {
start: { line: 3, character: 1 },
end: { line: 3, character: 6 },
},
},
],
},
})
})
@@ -138,13 +199,17 @@ test("resolveZedSelection handles emoji before the selected range", async () =>
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
text: "TARGET",
filePath: fixture.filePath,
source: "zed",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 7 },
},
ranges: [
{
text: "TARGET",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 7 },
},
},
],
},
})
})
@@ -162,13 +227,17 @@ test("resolveZedSelection handles reversed Zed byte offsets", async () => {
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
text: "TARGET",
filePath: fixture.filePath,
source: "zed",
selection: {
start: { line: 3, character: 1 },
end: { line: 3, character: 7 },
},
ranges: [
{
text: "TARGET",
selection: {
start: { line: 3, character: 1 },
end: { line: 3, character: 7 },
},
},
],
},
})
})
@@ -190,13 +190,17 @@ test("useEditorContext resets selection when reconnecting", async () => {
serverInfo: { name: "test", version: "0.0.0" },
})
expect(mounted.editor.selection()).toEqual({
text: "foo",
filePath: path.join(startupDirectory, "file.ts"),
source: "websocket",
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
ranges: [
{
text: "foo",
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
],
})
mounted.editor.reconnect(startupDirectory)
+6 -98
View File
@@ -1,120 +1,28 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../../fixture/fixture"
import * as App from "../../../src/cli/cmd/tui/app"
import { Rpc } from "@/util/rpc"
import { UI } from "../../../src/cli/ui"
import * as Timeout from "../../../src/util/timeout"
import * as Network from "../../../src/cli/network"
import * as Win32 from "../../../src/cli/cmd/tui/win32"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const stop = new Error("stop")
const seen = {
tui: [] as string[],
}
function setup() {
// Intentionally avoid mock.module() here: Bun keeps module overrides in cache
// and mock.restore() does not reset mock.module values. If this switches back
// to module mocks, later suites can see mocked @/config/tui and fail (e.g.
// plugin-loader tests expecting real TuiConfig.waitForDependencies). See:
// https://github.com/oven-sh/bun/issues/7823 and #12823.
spyOn(App, "tui").mockImplementation(async (input) => {
if (input.directory) seen.tui.push(input.directory)
throw stop
})
spyOn(Rpc, "client").mockImplementation(() => ({
call: async () => ({ url: "http://127.0.0.1" }) as never,
on: () => () => {},
}))
spyOn(UI, "error").mockImplementation(() => {})
spyOn(Timeout, "withTimeout").mockImplementation((input) => input)
spyOn(Network, "resolveNetworkOptions").mockResolvedValue({
mdns: false,
port: 0,
hostname: "127.0.0.1",
mdnsDomain: "opencode.local",
cors: [],
})
spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {})
spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined)
}
import { resolveThreadDirectory } from "../../../src/cli/cmd/tui/thread"
describe("tui thread", () => {
afterEach(() => {
mock.restore()
})
async function call(project?: string) {
const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread")
const args: Parameters<NonNullable<typeof TuiThreadCommand.handler>>[0] = {
_: [],
$0: "opencode",
project,
prompt: "hi",
model: undefined,
agent: undefined,
session: undefined,
continue: false,
fork: false,
port: 0,
hostname: "127.0.0.1",
mdns: false,
"mdns-domain": "opencode.local",
mdnsDomain: "opencode.local",
cors: [],
}
return TuiThreadCommand.handler(args)
}
async function check(project?: string) {
setup()
const cwd = process.cwd()
const pwd = process.env.PWD
const worker = globalThis.Worker
const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
await using tmp = await tmpdir({ git: true })
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
const type = process.platform === "win32" ? "junction" : "dir"
seen.tui.length = 0
await fs.symlink(tmp.path, link, type)
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
value: true,
})
globalThis.Worker = class extends EventTarget {
onerror = null
onmessage = null
onmessageerror = null
postMessage() {}
terminate() {}
} as unknown as typeof Worker
try {
process.chdir(tmp.path)
process.env.PWD = link
await expect(call(project)).rejects.toBe(stop)
expect(seen.tui[0]).toBe(tmp.path)
await fs.symlink(tmp.path, link, type)
expect(resolveThreadDirectory(project, link, tmp.path)).toBe(tmp.path)
} finally {
process.chdir(cwd)
if (pwd === undefined) delete process.env.PWD
else process.env.PWD = pwd
if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
else delete (process.stdin as { isTTY?: boolean }).isTTY
globalThis.Worker = worker
await fs.rm(link, { recursive: true, force: true }).catch(() => undefined)
}
}
// serial because both modify real env vars
test.serial("uses the real cwd when PWD points at a symlink", async () => {
test("uses the real cwd when PWD points at a symlink", async () => {
await check()
})
test.serial("uses the real cwd after resolving a relative project from PWD", async () => {
test("uses the real cwd after resolving a relative project from PWD", async () => {
await check(".")
})
})
@@ -1,67 +1,59 @@
import { test, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import path from "path"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { Config } from "@/config/config"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Color } from "@/util/color"
import { AppRuntime } from "../../src/effect/app-runtime"
import { testEffect } from "../lib/effect"
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
const agent = <A>(dir: string, fn: (svc: AgentSvc.Interface) => Effect.Effect<A>) =>
Effect.runPromise(provideInstance(dir)(AgentSvc.Service.use(fn)).pipe(Effect.provide(AgentSvc.defaultLayer)))
const it = testEffect(Layer.mergeAll(AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer))
test("agent color parsed from project config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
build: { color: "#FFA500" },
plan: { color: "primary" },
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await load()
const writeConfig = (dir: string, agent: Config.Info["agent"]) =>
Effect.promise(() =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent,
}),
),
)
it.live("agent color parsed from project config", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* writeConfig(dir, {
build: { color: "#FFA500" },
plan: { color: "primary" },
})
yield* Effect.gen(function* () {
const cfg = yield* Effect.promise(() => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())))
expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
expect(cfg.agent?.["plan"]?.color).toBe("primary")
},
})
})
}).pipe(provideInstance(dir))
}),
)
test("Agent.get includes color from config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
plan: { color: "#A855F7" },
build: { color: "accent" },
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const plan = await agent(tmp.path, (svc) => svc.get("plan"))
it.live("Agent.get includes color from config", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* writeConfig(dir, {
plan: { color: "#A855F7" },
build: { color: "accent" },
})
yield* Effect.gen(function* () {
const plan = yield* AgentSvc.Service.use((svc) => svc.get("plan"))
expect(plan?.color).toBe("#A855F7")
const build = await agent(tmp.path, (svc) => svc.get("build"))
const build = yield* AgentSvc.Service.use((svc) => svc.get("build"))
expect(build?.color).toBe("accent")
},
})
})
}).pipe(provideInstance(dir))
}),
)
test("Color.hexToAnsiBold converts valid hex to ANSI", () => {
const result = Color.hexToAnsiBold("#FFA500")
@@ -1,56 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { parseSSE } from "../../src/control-plane/sse"
import { resetDatabase } from "../fixture/db"
afterEach(async () => {
await resetDatabase()
})
function stream(chunks: string[]) {
return new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder()
chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk)))
controller.close()
},
})
}
describe("control-plane/sse", () => {
test("parses JSON events with CRLF and multiline data blocks", async () => {
const events: unknown[] = []
const stop = new AbortController()
await parseSSE(
stream([
'data: {"type":"one","properties":{"ok":true}}\r\n\r\n',
'data: {"type":"two",\r\ndata: "properties":{"n":2}}\r\n\r\n',
]),
stop.signal,
(event) => events.push(event),
)
expect(events).toEqual([
{ type: "one", properties: { ok: true } },
{ type: "two", properties: { n: 2 } },
])
})
test("falls back to sse.message for non-json payload", async () => {
const events: unknown[] = []
const stop = new AbortController()
await parseSSE(stream(["id: abc\nretry: 1500\ndata: hello world\n\n"]), stop.signal, (event) => events.push(event))
expect(events).toEqual([
{
type: "sse.message",
properties: {
data: "hello world",
id: "abc",
retry: 1500,
},
},
])
})
})
@@ -0,0 +1,1526 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import fs from "node:fs/promises"
import Http from "node:http"
import path from "node:path"
import { setTimeout as delay } from "node:timers/promises"
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { asc, eq } from "drizzle-orm"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Database } from "@/storage/db"
import { ProjectID } from "@/project/schema"
import { ProjectTable } from "@/project/project.sql"
import { Instance } from "@/project/instance"
import { Session as SessionNs } from "@/session/session"
import { SessionID, MessageID, PartID } from "@/session/schema"
import { SessionTable } from "@/session/session.sql"
import { ModelID, ProviderID } from "@/provider/schema"
import { SyncEvent } from "@/sync"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { resetDatabase } from "../fixture/db"
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { registerAdaptor } from "../../src/control-plane/adaptors"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import type { Target, WorkspaceAdaptor, WorkspaceInfo } from "../../src/control-plane/types"
import * as WorkspaceOld from "../../src/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
void Log.init({ print: false })
const testServerLayer = Layer.mergeAll(
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
WorkspaceOld.defaultLayer,
SessionNs.defaultLayer,
)
const it = testEffect(testServerLayer)
const originalWorkspacesFlag = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const originalEnv = {
OPENCODE_AUTH_CONTENT: process.env.OPENCODE_AUTH_CONTENT,
OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
}
type RecordedCreate = {
info: WorkspaceInfo
env: Record<string, string | undefined>
from?: WorkspaceInfo
}
type RecordedAdaptor = {
adaptor: WorkspaceAdaptor
calls: {
configure: WorkspaceInfo[]
create: RecordedCreate[]
remove: WorkspaceInfo[]
target: WorkspaceInfo[]
}
}
type FetchCall = {
url: URL
method: string
headers: Headers
bodyText?: string
json?: unknown
}
function unique(prefix: string) {
return `${prefix}-${Math.random().toString(36).slice(2)}`
}
function restoreEnv() {
Object.entries(originalEnv).forEach(([key, value]) => {
if (value === undefined) {
delete process.env[key]
return
}
process.env[key] = value
})
}
beforeEach(() => {
Database.close()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
restoreEnv()
})
afterEach(async () => {
mock.restore()
await Instance.disposeAll()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspacesFlag
restoreEnv()
await resetDatabase()
})
async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
await using tmp = await tmpdir({ git: true })
return Instance.provide({
directory: tmp.path,
fn: () => fn(tmp.path),
})
}
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
const createWorkspace = (input: WorkspaceOld.CreateInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input)))
const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
const removeWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.remove(id)))
const workspaceStatus = () => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.status()))
const isWorkspaceSyncing = (id: WorkspaceID) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.isSyncing(id)))
const startWorkspaceSyncing = (projectID: ProjectID) => {
void runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)))
}
const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)))
function captureGlobalEvents() {
const events: GlobalEvent[] = []
const handler = (event: GlobalEvent) => events.push(event)
GlobalBus.on("event", handler)
return {
events,
dispose() {
GlobalBus.off("event", handler)
},
}
}
async function eventually<T>(fn: () => T | Promise<T>, timeout = 1500) {
const started = Date.now()
let last: unknown
while (Date.now() - started < timeout) {
try {
return await fn()
} catch (err) {
last = err
await delay(10)
}
}
throw last ?? new Error("Timed out waiting for condition")
}
function eventuallyEffect(effect: Effect.Effect<void>, timeout = 1500) {
return Effect.gen(function* () {
const started = Date.now()
let last: unknown
while (Date.now() - started < timeout) {
const exit = yield* Effect.exit(effect)
if (exit._tag === "Success") return
last = exit.cause
yield* Effect.sleep("10 millis")
}
throw last ?? new Error("Timed out waiting for condition")
})
}
function recordedAdaptor(input: {
target: (info: WorkspaceInfo) => Target | Promise<Target>
configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise<WorkspaceInfo>
create?: (info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo) => Promise<void>
remove?: (info: WorkspaceInfo) => Promise<void>
}): RecordedAdaptor {
const calls: RecordedAdaptor["calls"] = {
configure: [],
create: [],
remove: [],
target: [],
}
return {
calls,
adaptor: {
name: "recorded",
description: "recorded",
configure(info) {
calls.configure.push(structuredClone(info))
return input.configure?.(info) ?? info
},
async create(info, env, from) {
calls.create.push({
info: structuredClone(info),
env: { ...env },
from: from ? structuredClone(from) : undefined,
})
await input.create?.(info, env, from)
},
async remove(info) {
calls.remove.push(structuredClone(info))
await input.remove?.(info)
},
target(info) {
calls.target.push(structuredClone(info))
return input.target(info)
},
},
}
}
function localAdaptor(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise<void> }) {
return recordedAdaptor({
configure(info) {
return { ...info, directory: dir }
},
async create() {
if (input?.createDir === false) return
await fs.mkdir(dir, { recursive: true })
},
remove: input?.remove,
target() {
return { type: "local", directory: dir }
},
})
}
function remoteAdaptor(url: string, input?: { directory?: string | null; headers?: HeadersInit }) {
return recordedAdaptor({
configure(info) {
return { ...info, directory: input?.directory ?? info.directory }
},
target() {
return { type: "remote", url, headers: input?.headers }
},
})
}
function eventStreamResponse(events: unknown[] = [], keepOpen = true) {
const encoder = new TextEncoder()
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
if (keepOpen) controller.enqueue(encoder.encode(":\n\n"))
events.forEach((event) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)))
if (!keepOpen) controller.close()
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
)
}
function serverUrl() {
return Effect.gen(function* () {
return HttpServer.formatAddress((yield* HttpServer.HttpServer).address)
})
}
function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<WorkspaceInfo>): WorkspaceInfo {
return {
id: input?.id ?? WorkspaceID.ascending(),
type,
name: input?.name ?? unique("workspace"),
branch: input?.branch ?? null,
directory: input?.directory ?? null,
extra: input?.extra ?? null,
projectID,
}
}
function insertWorkspace(info: WorkspaceInfo) {
Database.use((db) =>
db
.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
})
.run(),
)
}
function insertProject(id: ProjectID, worktree: string) {
Database.use((db) =>
db
.insert(ProjectTable)
.values({
id,
worktree,
vcs: null,
name: null,
time_created: Date.now(),
time_updated: Date.now(),
sandboxes: [],
})
.run(),
)
}
function attachSessionToWorkspace(sessionID: SessionID, workspaceID: WorkspaceID) {
Database.use((db) =>
db.update(SessionTable).set({ workspace_id: workspaceID }).where(eq(SessionTable.id, sessionID)).run(),
)
}
function sessionSequence(sessionID: SessionID) {
return Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.get(),
)?.seq
}
function eventRows(sessionID: SessionID) {
return Database.use((db) =>
db
.select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
}
function sessionUpdatedType() {
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
}
function replaceSessionEvents(sessionID: SessionID, count: number) {
Database.use((db) => {
db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run()
if (count === 0) return
db.insert(EventSequenceTable)
.values({ aggregate_id: sessionID, seq: count - 1 })
.run()
db.insert(EventTable)
.values(
Array.from({ length: count }, (_, i) => ({
id: `evt_${unique(`manual-${i}`)}`,
aggregate_id: sessionID,
seq: i,
type: sessionUpdatedType(),
data: { sessionID, info: { title: `manual ${i}` } },
})),
)
.run()
})
}
describe("workspace-old schemas and exports", () => {
test("keeps the historical event type names", () => {
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore")
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
})
test("validates create input with workspace id, project id, branch, type, and extra", () => {
const input = {
id: WorkspaceID.ascending("wrk_schema_create"),
type: "worktree",
branch: "feature/schema",
projectID: ProjectID.make("project-schema"),
extra: { nested: true },
}
expect(WorkspaceOld.CreateInput.zod.parse(input)).toEqual(input)
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
})
test("validates session restore input", () => {
const input = {
workspaceID: WorkspaceID.ascending("wrk_schema_restore"),
sessionID: SessionID.descending("ses_schema_restore"),
}
expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input)
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow()
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow()
})
})
describe("workspace-old CRUD", () => {
test("get returns undefined for a missing workspace", async () => {
await withInstance(async () => {
expect(await getWorkspace(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined()
})
})
test("list maps database rows, filters by project, and sorts by id", async () => {
await withInstance(async () => {
const otherProjectID = ProjectID.make("project-other")
insertProject(otherProjectID, "/tmp/other")
const a = workspaceInfo(Instance.project.id, "manual", {
id: WorkspaceID.ascending("wrk_a_list"),
branch: "a",
directory: "/a",
extra: { a: true },
})
const b = workspaceInfo(Instance.project.id, "manual", {
id: WorkspaceID.ascending("wrk_b_list"),
branch: "b",
directory: "/b",
extra: ["b"],
})
const other = workspaceInfo(otherProjectID, "manual", { id: WorkspaceID.ascending("wrk_c_list") })
insertWorkspace(b)
insertWorkspace(other)
insertWorkspace(a)
expect(await listWorkspaces(Instance.project)).toEqual([a, b])
})
})
test("create configures, persists, creates, starts local sync, and passes environment", async () => {
await withInstance(async (dir) => {
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ test: { type: "api", key: "secret" } })
process.env.OTEL_EXPORTER_OTLP_HEADERS = "authorization=otel"
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://otel.test"
process.env.OTEL_RESOURCE_ATTRIBUTES = "service.name=opencode-test"
const workspaceID = WorkspaceID.ascending("wrk_create_local")
const type = unique("create-local")
const targetDir = path.join(dir, "created-local")
const recorded = recordedAdaptor({
configure(info) {
return {
...info,
branch: "configured-branch",
name: "Configured Name",
directory: targetDir,
extra: { configured: true },
}
},
async create() {
await fs.mkdir(targetDir, { recursive: true })
},
target() {
return { type: "local", directory: targetDir }
},
})
registerAdaptor(Instance.project.id, type, recorded.adaptor)
const info = await createWorkspace({
id: workspaceID,
type,
branch: null,
projectID: Instance.project.id,
extra: null,
})
expect(info).toEqual({
id: workspaceID,
type,
branch: "configured-branch",
name: "Configured Name",
directory: targetDir,
extra: { configured: true },
projectID: Instance.project.id,
})
expect(await getWorkspace(workspaceID)).toEqual(info)
expect(await listWorkspaces(Instance.project)).toEqual([info])
expect(recorded.calls.configure).toHaveLength(1)
expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null })
expect(recorded.calls.create).toHaveLength(1)
expect(recorded.calls.create[0].info).toEqual(info)
expect(JSON.parse(recorded.calls.create[0].env.OPENCODE_AUTH_CONTENT ?? "{}")).toEqual({
test: { type: "api", key: "secret" },
})
expect(recorded.calls.create[0].env.OPENCODE_WORKSPACE_ID).toBe(workspaceID)
expect(recorded.calls.create[0].env.OPENCODE_EXPERIMENTAL_WORKSPACES).toBe("true")
expect(recorded.calls.create[0].env.OTEL_EXPORTER_OTLP_HEADERS).toBe("authorization=otel")
expect(recorded.calls.create[0].env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://otel.test")
expect(recorded.calls.create[0].env.OTEL_RESOURCE_ATTRIBUTES).toBe("service.name=opencode-test")
expect((await workspaceStatus()).find((item) => item.workspaceID === workspaceID)?.status).toBe("connected")
await removeWorkspace(workspaceID)
expect((await workspaceStatus()).find((item) => item.workspaceID === workspaceID)?.status).toBeUndefined()
})
})
test("create propagates configure failures and does not insert a workspace", async () => {
await withInstance(async () => {
const type = unique("configure-failure")
registerAdaptor(
Instance.project.id,
type,
recordedAdaptor({
configure() {
throw new Error("configure exploded")
},
target() {
return { type: "local", directory: "/unused" }
},
}).adaptor,
)
await expect(
createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }),
).rejects.toThrow("configure exploded")
expect(await listWorkspaces(Instance.project)).toEqual([])
})
})
test("create leaves the inserted row when adaptor create fails", async () => {
await withInstance(async () => {
const type = unique("create-failure")
const recorded = recordedAdaptor({
async create() {
throw new Error("create exploded")
},
target() {
return { type: "local", directory: "/unused" }
},
})
registerAdaptor(Instance.project.id, type, recorded.adaptor)
await expect(
createWorkspace({ type, branch: "branch", projectID: Instance.project.id, extra: { x: 1 } }),
).rejects.toThrow("create exploded")
const rows = await listWorkspaces(Instance.project)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ type, branch: "branch", extra: { x: 1 } })
expect(recorded.calls.target).toHaveLength(0)
await removeWorkspace(rows[0].id)
})
})
test("create returns after a local workspace reports error", async () => {
await withInstance(async (dir) => {
const type = unique("local-error")
const missing = path.join(dir, "missing-local-target")
const recorded = localAdaptor(missing, { createDir: false })
registerAdaptor(Instance.project.id, type, recorded.adaptor)
const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null })
expect(info.directory).toBe(missing)
expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBe("error")
await removeWorkspace(info.id)
})
})
it.live("remote create connects to routed event and history endpoints", () => {
const calls: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
}
calls.push(call)
if (call.url.pathname === "/base/global/event")
return HttpServerResponse.fromWeb(eventStreamResponse([], false))
if (call.url.pathname === "/base/sync/history") return yield* HttpServerResponse.json([])
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const type = unique("remote-create")
const recorded = remoteAdaptor(`${url}/base/?ignored=1#hash`, { directory: dir })
registerAdaptor(Instance.project.id, type, recorded.adaptor)
const info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null })
expect(
calls.map((call) => `${call.method} ${call.url.pathname}${call.url.search}${call.url.hash}`),
).toEqual(["GET /base/global/event", "POST /base/sync/history"])
expect(calls[1].json).toEqual({})
expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("connected")
expect(yield* workspace.isSyncing(info.id)).toBe(true)
yield* workspace.remove(info.id)
expect(yield* workspace.isSyncing(info.id)).toBe(false)
expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
}),
{ git: true },
)
})
})
test("remove returns undefined for a missing workspace", async () => {
await withInstance(async () => {
expect(await removeWorkspace(WorkspaceID.ascending("wrk_missing_remove"))).toBeUndefined()
})
})
test("remove deletes the workspace, associated sessions, adaptor resources, and status", async () => {
await withInstance(async (dir) => {
const type = unique("remove-local")
const recorded = localAdaptor(path.join(dir, "remove-local"))
registerAdaptor(Instance.project.id, type, recorded.adaptor)
const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null })
const one = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
const two = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(one.id, info.id)
attachSessionToWorkspace(two.id, info.id)
const removed = await removeWorkspace(info.id)
expect(removed).toEqual(info)
expect(await getWorkspace(info.id)).toBeUndefined()
expect(recorded.calls.remove).toEqual([info])
expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
expect(
Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, info.id)).all(),
),
).toEqual([])
})
})
test("remove still deletes the row when the adaptor cannot remove resources", async () => {
await withInstance(async () => {
const type = unique("remove-throws")
const info = workspaceInfo(Instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") })
registerAdaptor(
Instance.project.id,
type,
recordedAdaptor({
async remove() {
throw new Error("remove exploded")
},
target() {
return { type: "local", directory: "/unused" }
},
}).adaptor,
)
insertWorkspace(info)
expect(await removeWorkspace(info.id)).toEqual(info)
expect(await getWorkspace(info.id)).toBeUndefined()
})
})
})
describe("workspace-old sync state", () => {
test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => {
await withInstance(async (dir) => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
const type = unique("flag-disabled")
const info = workspaceInfo(Instance.project.id, type)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, info.id)
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, localAdaptor(path.join(dir, "flag-disabled")).adaptor)
startWorkspaceSyncing(Instance.project.id)
await delay(25)
expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
})
})
test("startWorkspaceSyncing starts only workspaces with sessions", async () => {
await withInstance(async (dir) => {
const withSessionType = unique("with-session")
const withoutSessionType = unique("without-session")
const withSession = workspaceInfo(Instance.project.id, withSessionType)
const withoutSession = workspaceInfo(Instance.project.id, withoutSessionType)
const withSessionDir = path.join(dir, "with-session")
const withoutSessionDir = path.join(dir, "without-session")
await fs.mkdir(withSessionDir, { recursive: true })
await fs.mkdir(withoutSessionDir, { recursive: true })
insertWorkspace(withSession)
insertWorkspace(withoutSession)
registerAdaptor(Instance.project.id, withSessionType, localAdaptor(withSessionDir).adaptor)
registerAdaptor(Instance.project.id, withoutSessionType, localAdaptor(withoutSessionDir).adaptor)
attachSessionToWorkspace(
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
withSession.id,
)
startWorkspaceSyncing(Instance.project.id)
await eventually(() =>
workspaceStatus().then((status) =>
expect(status.find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"),
),
)
expect((await workspaceStatus()).find((item) => item.workspaceID === withoutSession.id)?.status).toBeUndefined()
await removeWorkspace(withSession.id)
await removeWorkspace(withoutSession.id)
})
})
test("local start reports error when the target directory is missing", async () => {
await withInstance(async (dir) => {
const type = unique("missing-local")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdaptor(
Instance.project.id,
type,
localAdaptor(path.join(dir, "missing-target"), { createDir: false }).adaptor,
)
attachSessionToWorkspace(
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
info.id,
)
startWorkspaceSyncing(Instance.project.id)
await eventually(() =>
workspaceStatus().then((status) =>
expect(status.find((item) => item.workspaceID === info.id)?.status).toBe("error"),
),
)
expect(await isWorkspaceSyncing(info.id)).toBe(false)
await removeWorkspace(info.id)
})
})
test("duplicate local status updates are suppressed", async () => {
await withInstance(async (dir) => {
const captured = captureGlobalEvents()
try {
const type = unique("dedupe-local")
const info = workspaceInfo(Instance.project.id, type)
const target = path.join(dir, "dedupe-local")
await fs.mkdir(target, { recursive: true })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, localAdaptor(target).adaptor)
attachSessionToWorkspace(
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
info.id,
)
startWorkspaceSyncing(Instance.project.id)
startWorkspaceSyncing(Instance.project.id)
await eventually(() =>
workspaceStatus().then((status) =>
expect(status.find((item) => item.workspaceID === info.id)?.status).toBe("connected"),
),
)
expect(
captured.events.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type,
),
).toHaveLength(1)
await removeWorkspace(info.id)
} finally {
captured.dispose()
}
})
})
it.live("remote start emits disconnected, connecting, and connected then refuses duplicate listeners", () => {
const calls: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
}
calls.push(call)
if (call.url.pathname === "/sync/global/event") return HttpServerResponse.fromWeb(eventStreamResponse())
if (call.url.pathname === "/sync/sync/history") return HttpServerResponse.fromWeb(Response.json([]))
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("remote-start")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sync`).adaptor)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* eventuallyEffect(
Effect.gen(function* () {
expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe(
"connected",
)
}),
)
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* Effect.sleep("25 millis")
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type,
)
.map((event) => event.payload.properties.status),
).toEqual(["disconnected", "connecting", "connected"])
expect(calls.filter((call) => call.url.pathname === "/sync/global/event")).toHaveLength(1)
expect(calls.filter((call) => call.url.pathname === "/sync/sync/history")).toHaveLength(1)
expect(yield* workspace.isSyncing(info.id)).toBe(true)
yield* workspace.remove(info.id)
expect(yield* workspace.isSyncing(info.id)).toBe(false)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
it.live("remote connection HTTP failures set error and clear syncing", () =>
Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
if (new URL(req.url, "http://localhost").pathname === "/failed/global/event")
return HttpServerResponse.text("nope", { status: 503 })
return HttpServerResponse.fromWeb(Response.json([]))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("remote-connect-fail")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/failed`).adaptor)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* eventuallyEffect(
Effect.gen(function* () {
expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error")
}),
)
expect(yield* workspace.isSyncing(info.id)).toBe(false)
yield* workspace.remove(info.id)
}),
{ git: true },
)
}),
)
it.live("remote history HTTP failures set error", () =>
Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const url = new URL(req.url, "http://localhost")
if (url.pathname === "/history-failed/global/event")
return HttpServerResponse.fromWeb(eventStreamResponse([], false))
if (url.pathname === "/history-failed/sync/history")
return HttpServerResponse.text("history failed", { status: 500 })
return HttpServerResponse.fromWeb(Response.json([]))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("remote-history-fail")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history-failed`).adaptor)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* eventuallyEffect(
Effect.gen(function* () {
expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error")
}),
)
expect(yield* workspace.isSyncing(info.id)).toBe(false)
yield* workspace.remove(info.id)
}),
{ git: true },
)
}),
)
it.live("sync history sends the local sequence fence and replays returned events in workspace context", () => {
const historyBodies: unknown[] = []
let historySessionID: SessionID | undefined
let historyNextSeq = 0
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const url = new URL(req.url, "http://localhost")
if (url.pathname === "/history/global/event") return HttpServerResponse.fromWeb(eventStreamResponse())
if (url.pathname === "/history/sync/history") {
historyBodies.push(bodyText ? JSON.parse(bodyText) : undefined)
return HttpServerResponse.fromWeb(
Response.json([
{
id: `evt_${unique("history")}`,
aggregate_id: historySessionID!,
seq: historyNextSeq,
type: sessionUpdatedType(),
data: { sessionID: historySessionID!, info: { title: "from history" } },
},
]),
)
}
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("history-replay")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history`).adaptor)
const session = yield* sessionSvc.create({ title: "before history" })
attachSessionToWorkspace(session.id, info.id)
historySessionID = session.id
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* eventuallyEffect(
Effect.gen(function* () {
expect((yield* sessionSvc.get(session.id)).title).toBe("from history")
}),
)
expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }])
expect(
captured.events.some(
(event) =>
event.workspace === info.id &&
event.payload.type === "sync" &&
event.payload.syncEvent.seq === historyNextSeq,
),
).toBe(true)
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
it.live("SSE forwards non-heartbeat events and ignores heartbeats", () =>
Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const url = new URL(req.url, "http://localhost")
if (url.pathname === "/sse-forward/global/event")
return HttpServerResponse.fromWeb(
eventStreamResponse(
[
{ directory: "remote-dir", project: "remote-project", payload: { type: "server.heartbeat" } },
{
directory: "remote-dir",
project: "remote-project",
payload: { type: "custom.remote", properties: { ok: true } },
},
],
false,
),
)
if (url.pathname === "/sse-forward/sync/history") return HttpServerResponse.fromWeb(Response.json([]))
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("sse-forward")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-forward`).adaptor)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* eventuallyEffect(
Effect.sync(() =>
expect(
captured.events.some(
(event) => event.workspace === info.id && event.payload.type === "custom.remote",
),
).toBe(true),
),
)
expect(
captured.events.some(
(event) => event.workspace === info.id && event.payload.type === "server.heartbeat",
),
).toBe(false)
expect(
captured.events.find((event) => event.workspace === info.id && event.payload.type === "custom.remote"),
).toMatchObject({
directory: "remote-dir",
project: "remote-project",
payload: { properties: { ok: true } },
})
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
}),
)
it.live("SSE sync events are replayed and forwarded", () => {
let sseSessionID: SessionID | undefined
let sseNextSeq = 0
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const url = new URL(req.url, "http://localhost")
if (url.pathname === "/sse-sync/global/event")
return HttpServerResponse.fromWeb(
eventStreamResponse(
[
{
directory: "remote-dir",
project: "remote-project",
payload: {
type: "sync",
syncEvent: {
id: `evt_${unique("sse")}`,
aggregateID: sseSessionID!,
seq: sseNextSeq,
type: sessionUpdatedType(),
data: { sessionID: sseSessionID!, info: { title: "from sse" } },
},
},
},
],
false,
),
)
if (url.pathname === "/sse-sync/sync/history") return HttpServerResponse.fromWeb(Response.json([]))
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("sse-sync")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-sync`).adaptor)
const session = yield* sessionSvc.create({ title: "before sse" })
attachSessionToWorkspace(session.id, info.id)
sseSessionID = session.id
sseNextSeq = (sessionSequence(session.id) ?? -1) + 1
yield* workspace.startWorkspaceSyncing(Instance.project.id)
yield* eventuallyEffect(
Effect.gen(function* () {
expect((yield* sessionSvc.get(session.id)).title).toBe("from sse")
}),
)
expect(
captured.events.some(
(event) =>
event.workspace === info.id &&
event.payload.type === "sync" &&
event.payload.syncEvent.seq === sseNextSeq,
),
).toBe(true)
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
})
describe("workspace-old waitForSync", () => {
test("returns immediately for an empty fence", async () => {
await withInstance(async () => {
await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_empty"), {})).resolves.toBeUndefined()
})
})
test("returns immediately when the stored sequence already satisfies the fence", async () => {
await withInstance(async () => {
const sessionID = SessionID.descending("ses_wait_done")
Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run())
await expect(
waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_done"), { [sessionID]: 4 }),
).resolves.toBeUndefined()
await expect(
waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_done_2"), { [sessionID]: 3 }),
).resolves.toBeUndefined()
})
})
test("waits until the database reaches the requested sequence and a workspace event arrives", async () => {
await withInstance(async () => {
const workspaceID = WorkspaceID.ascending("wrk_wait_event")
const sessionID = SessionID.descending("ses_wait_event")
Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 1 }).run())
const waited = waitForWorkspaceSync(workspaceID, { [sessionID]: 2 })
await delay(10)
Database.use((db) =>
db.update(EventSequenceTable).set({ seq: 2 }).where(eq(EventSequenceTable.aggregate_id, sessionID)).run(),
)
GlobalBus.emit("event", { workspace: workspaceID, payload: { type: "anything" } })
await expect(waited).resolves.toBeUndefined()
})
})
test("a sync event for a different workspace can also release the fence", async () => {
await withInstance(async () => {
const workspaceID = WorkspaceID.ascending("wrk_wait_sync_any")
const sessionID = SessionID.descending("ses_wait_sync_any")
Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 0 }).run())
const waited = waitForWorkspaceSync(workspaceID, { [sessionID]: 1 })
await delay(10)
Database.use((db) =>
db.update(EventSequenceTable).set({ seq: 1 }).where(eq(EventSequenceTable.aggregate_id, sessionID)).run(),
)
GlobalBus.emit("event", {
workspace: WorkspaceID.ascending("wrk_other_workspace"),
payload: { type: "sync" },
})
await expect(waited).resolves.toBeUndefined()
})
})
test("rejects with the abort reason when aborted", async () => {
await withInstance(async () => {
const abort = new AbortController()
const reason = new Error("caller aborted")
const waited = waitForWorkspaceSync(
WorkspaceID.ascending("wrk_wait_abort"),
{ [SessionID.descending("ses_wait_abort")]: 1 },
abort.signal,
)
abort.abort(reason)
await expect(waited).rejects.toMatchObject({
_tag: "WorkspaceSyncAbortedError",
message: reason.message,
cause: reason,
})
})
})
test("times out with the requested fence in the error message", async () => {
await withInstance(async () => {
const sessionID = SessionID.descending("ses_wait_timeout")
await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 })).rejects.toThrow(
`Timed out waiting for sync fence: {"${sessionID}":1}`,
)
})
}, 7000)
})
describe("workspace-old sessionRestore", () => {
test("throws when the workspace is missing", async () => {
await withInstance(async () => {
await expect(
restoreWorkspaceSession({
workspaceID: WorkspaceID.ascending("wrk_restore_missing"),
sessionID: SessionID.descending("ses_restore_missing_workspace"),
}),
).rejects.toThrow("Workspace not found: wrk_restore_missing")
})
})
test("throws when switching a missing session fails", async () => {
await withInstance(async (dir) => {
const type = unique("restore-missing-session")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
await expect(
restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }),
).rejects.toThrow("NotFoundError")
await removeWorkspace(info.id)
})
})
it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
}
if (call.url.pathname === "/restore/sync/replay") {
replay.push(call)
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-remote")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdaptor(
Instance.project.id,
type,
remoteAdaptor(`${url}/restore/?ignored=1#hash`, {
directory: dir,
headers: { authorization: "Bearer restore" },
}).adaptor,
)
const session = yield* sessionSvc.create({ title: "restore remote" })
replaceSessionEvents(session.id, 24)
const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })
expect(result).toEqual({ total: 3 })
expect(replay).toHaveLength(3)
expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([
"/restore/sync/replay",
"/restore/sync/replay",
"/restore/sync/replay",
])
expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true)
expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true)
expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5])
expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir])
expect(
replay.flatMap((call) =>
(call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq),
),
).toEqual(Array.from({ length: 25 }, (_, i) => i))
expect(
(replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1),
).toMatchObject({
seq: 24,
type: sessionUpdatedType(),
data: { sessionID: session.id, info: { workspaceID: info.id } },
})
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
it.live("remote restore sends an empty directory string when the workspace directory is null", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("restore-null-dir")
const info = workspaceInfo(Instance.project.id, type, { directory: null })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/null-dir`, { directory: null }).adaptor)
const session = yield* sessionSvc.create({ title: "null dir" })
replaceSessionEvents(session.id, 0)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 1,
})
expect((replay[0].json as { directory: string }).directory).toBe("")
expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1)
yield* workspace.remove(info.id)
}),
{ git: true },
)
})
})
it.live("remote restore failures include status and body and do not emit completed batch progress", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.text("replay failed", { status: 503 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-remote-fail")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/fail`, { directory: dir }).adaptor)
const session = yield* sessionSvc.create({ title: "restore fail" })
replaceSessionEvents(session.id, 11)
const error = yield* Effect.flip(
workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }),
)
expect((error as Error).message).toContain(
`Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`,
)
expect(replay).toHaveLength(1)
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
it.live("local restore replays batches and emits progress", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-local")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
const session = yield* sessionSvc.create({ title: "restore local" })
replaceSessionEvents(session.id, 20)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 3,
})
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i))
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
),
)
it.live("session restore includes real message and part events in sequence order", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("restore-real-events")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/real`, { directory: dir }).adaptor)
const session = yield* sessionSvc.create({ title: "real events" })
for (let i = 0; i < 3; i++) {
const msg = yield* sessionSvc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
yield* sessionSvc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: msg.id,
type: "text",
text: `message ${i}`,
})
}
const before = eventRows(session.id)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 1,
})
const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events
expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1])
expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type))
expect(posted.at(-1)?.type).toBe(sessionUpdatedType())
yield* workspace.remove(info.id)
}),
{ git: true },
)
})
})
})
@@ -1,12 +1,15 @@
import { expect, test } from "bun:test"
import { expect } from "bun:test"
import { Context, Effect, Layer, Logger } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppRuntime } from "../../src/effect/app-runtime"
import { EffectBridge } from "@/effect/bridge"
import { InstanceRef } from "../../src/effect/instance-ref"
import * as EffectLogger from "@opencode-ai/core/effect/logger"
import { makeRuntime } from "../../src/effect/run-service"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(CrossSpawnSpawner.defaultLayer)
function check(loggers: ReadonlySet<Logger.Logger<unknown, any>>) {
return {
@@ -17,56 +20,58 @@ function check(loggers: ReadonlySet<Logger.Logger<unknown, any>>) {
}
}
test("makeRuntime installs EffectLogger through Observability.layer", async () => {
class Dummy extends Context.Service<Dummy, { readonly current: () => Effect.Effect<ReturnType<typeof check>> }>()(
"@test/Dummy",
) {}
it.live("makeRuntime installs EffectLogger through Observability.layer", () =>
Effect.gen(function* () {
class Dummy extends Context.Service<Dummy, { readonly current: () => Effect.Effect<ReturnType<typeof check>> }>()(
"@test/Dummy",
) {}
const layer = Layer.effect(
Dummy,
Effect.gen(function* () {
return Dummy.of({
current: () => Effect.map(Effect.service(Logger.CurrentLoggers), check),
})
}),
)
const layer = Layer.effect(
Dummy,
Effect.gen(function* () {
return Dummy.of({
current: () => Effect.map(Effect.service(Logger.CurrentLoggers), check),
})
}),
)
const rt = makeRuntime(Dummy, layer)
const current = await rt.runPromise((svc) => svc.current())
const current = yield* Effect.promise(() => makeRuntime(Dummy, layer).runPromise((svc) => svc.current()))
expect(current.effectLogger).toBe(true)
expect(current.defaultLogger).toBe(false)
})
expect(current.effectLogger).toBe(true)
expect(current.defaultLogger).toBe(false)
}),
)
test("AppRuntime also installs EffectLogger through Observability.layer", async () => {
const current = await AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check))
it.live("AppRuntime also installs EffectLogger through Observability.layer", () =>
Effect.gen(function* () {
const current = yield* Effect.promise(() =>
AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check)),
)
expect(current.effectLogger).toBe(true)
expect(current.defaultLogger).toBe(false)
})
expect(current.effectLogger).toBe(true)
expect(current.defaultLogger).toBe(false)
}),
)
test("AppRuntime attaches InstanceRef from ALS", async () => {
await using tmp = await tmpdir({ git: true })
const dir = await Instance.provide({
directory: tmp.path,
fn: () =>
it.live("AppRuntime attaches InstanceRef from ALS", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const current = yield* Effect.promise(() =>
AppRuntime.runPromise(
Effect.gen(function* () {
return (yield* InstanceRef)?.directory
}),
),
})
).pipe(provideInstance(dir))
expect(dir).toBe(tmp.path)
})
expect(current).toBe(dir)
}),
)
test("EffectBridge preserves logger and instance context across async boundaries", async () => {
await using tmp = await tmpdir({ git: true })
const result = await Instance.provide({
directory: tmp.path,
fn: () =>
it.live("EffectBridge preserves logger and instance context across async boundaries", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const result = yield* Effect.promise(() =>
AppRuntime.runPromise(
Effect.gen(function* () {
const bridge = yield* EffectBridge.make()
@@ -84,9 +89,10 @@ test("EffectBridge preserves logger and instance context across async boundaries
)
}),
),
})
).pipe(provideInstance(dir))
expect(result.directory).toBe(tmp.path)
expect(result.effectLogger).toBe(true)
expect(result.defaultLogger).toBe(false)
})
expect(result.directory).toBe(dir)
expect(result.effectLogger).toBe(true)
expect(result.defaultLogger).toBe(false)
}),
)
@@ -0,0 +1,65 @@
import { describe, expect } from "bun:test"
import { Config, ConfigProvider, Context, Effect, Layer, Option } from "effect"
import { ConfigService } from "../../src/effect/config-service"
import { it } from "../lib/effect"
class TestConfig extends ConfigService.Service<TestConfig>()("@test/ConfigService", {
name: Config.string("NAME"),
token: Config.string("TOKEN").pipe(Config.option),
port: Config.number("PORT").pipe(Config.withDefault(3000)),
}) {}
const fromConfig = (input: Record<string, unknown>) =>
TestConfig.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input))))
const readConfig = TestConfig.useSync((config) => config)
describe("ConfigService", () => {
it.effect("defaultLayer parses values from the active ConfigProvider", () =>
Effect.gen(function* () {
const config = yield* readConfig.pipe(
Effect.provide(
fromConfig({
NAME: "kit",
TOKEN: "secret",
PORT: "4096",
}),
),
)
expect(config.name).toBe("kit")
expect(config.token).toEqual(Option.some("secret"))
expect(config.port).toBe(4096)
}),
)
it.effect("defaultLayer applies Effect Config defaults", () =>
Effect.gen(function* () {
const config = yield* readConfig.pipe(Effect.provide(fromConfig({ NAME: "kit" })))
expect(config.name).toBe("kit")
expect(config.token).toEqual(Option.none())
expect(config.port).toBe(3000)
}),
)
it.effect("layer provides an already parsed service value", () =>
Effect.gen(function* () {
const config = yield* readConfig.pipe(
Effect.provide(
TestConfig.layer({
name: "direct",
token: Option.some("parsed"),
port: 9000,
}),
),
)
expect(config).toEqual({
name: "direct",
token: Option.some("parsed"),
port: 9000,
} satisfies Context.Service.Shape<typeof TestConfig>)
}),
)
})
@@ -1,482 +1,393 @@
import { afterEach, expect, test } from "bun:test"
import { Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, Context } from "effect"
import { afterEach, expect } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { $ } from "bun"
import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
async function access<A, E>(state: InstanceState.InstanceState<A, E>, dir: string) {
return Instance.provide({
directory: dir,
fn: () => Effect.runPromise(InstanceState.get(state)),
})
}
const it = testEffect(CrossSpawnSpawner.defaultLayer)
const access = <A, E>(state: InstanceState.InstanceState<A, E>, dir: string) =>
InstanceState.get(state).pipe(provideInstance(dir))
const tmpdirGitScoped = Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* Effect.promise(() => $`git commit --allow-empty --amend -m ${`root commit ${dir}`}`.cwd(dir).quiet())
return dir
})
afterEach(async () => {
await Instance.disposeAll()
})
test("InstanceState caches values per directory", async () => {
await using tmp = await tmpdir()
let n = 0
it.live("InstanceState caches values per directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
let n = 0
const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
const a = yield* access(state, dir)
const b = yield* access(state, dir)
const a = yield* Effect.promise(() => access(state, tmp.path))
const b = yield* Effect.promise(() => access(state, tmp.path))
expect(a).toBe(b)
expect(n).toBe(1)
}),
)
expect(a).toBe(b)
expect(n).toBe(1)
}),
),
)
})
it.live("InstanceState isolates directories", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
let n = 0
const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
test("InstanceState isolates directories", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
let n = 0
const a = yield* access(state, one)
const b = yield* access(state, two)
const c = yield* access(state, one)
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
expect(a).toBe(c)
expect(a).not.toBe(b)
expect(n).toBe(2)
}),
)
const a = yield* Effect.promise(() => access(state, one.path))
const b = yield* Effect.promise(() => access(state, two.path))
const c = yield* Effect.promise(() => access(state, one.path))
expect(a).toBe(c)
expect(a).not.toBe(b)
expect(n).toBe(2)
}),
),
)
})
test("InstanceState invalidates on reload", async () => {
await using tmp = await tmpdir()
const seen: string[] = []
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() =>
Effect.acquireRelease(
Effect.sync(() => ({ n: ++n })),
(value) =>
Effect.sync(() => {
seen.push(String(value.n))
}),
),
)
const a = yield* Effect.promise(() => access(state, tmp.path))
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
const b = yield* Effect.promise(() => access(state, tmp.path))
expect(a).not.toBe(b)
expect(seen).toEqual(["1"])
}),
),
)
})
test("InstanceState invalidates on disposeAll", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
const seen: string[] = []
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.acquireRelease(
Effect.sync(() => ({ dir: ctx.directory })),
(value) =>
Effect.sync(() => {
seen.push(value.dir)
}),
),
)
yield* Effect.promise(() => access(state, one.path))
yield* Effect.promise(() => access(state, two.path))
yield* Effect.promise(() => Instance.disposeAll())
expect(seen.sort()).toEqual([one.path, two.path].sort())
}),
),
)
})
test("InstanceState.get reads the current directory lazily", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/InstanceStateLazy") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
const get = InstanceState.get(state)
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* get
it.live("InstanceState invalidates on reload", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const seen: string[] = []
let n = 0
const state = yield* InstanceState.make(() =>
Effect.acquireRelease(
Effect.sync(() => ({ n: ++n })),
(value) =>
Effect.sync(() => {
seen.push(String(value.n))
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const a = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
const b = await Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(a).toBe(one.path)
expect(b).toBe(two.path)
} finally {
await rt.dispose()
}
})
test("InstanceState preserves directory across async boundaries", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
await using three = await tmpdir({ git: true })
interface Api {
readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
}
class Test extends Context.Service<Test, Api>()("@test/InstanceStateAsync") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.sync(() => ({
directory: ctx.directory,
worktree: ctx.worktree,
project: ctx.project.id,
})),
)
return Test.of({
get: Effect.fn("Test.get")(function* () {
yield* Effect.promise(() => Bun.sleep(1))
yield* Effect.sleep(Duration.millis(1))
for (let i = 0; i < 100; i++) {
yield* Effect.yieldNow
}
for (let i = 0; i < 100; i++) {
yield* Effect.promise(() => Promise.resolve())
}
yield* Effect.sleep(Duration.millis(2))
yield* Effect.promise(() => Bun.sleep(1))
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const [a, b, c] = await Promise.all([
Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
Instance.provide({
directory: three.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
])
expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project })
expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project })
expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project })
expect(a.project).not.toBe(b.project)
expect(a.project).not.toBe(c.project)
expect(b.project).not.toBe(c.project)
} finally {
await rt.dispose()
}
})
test("InstanceState survives high-contention concurrent access", async () => {
const N = 20
const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir()))
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/HighContention") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* () {
// Interleave many async hops to maximize chance of ALS corruption
for (let i = 0; i < 10; i++) {
yield* Effect.promise(() => Bun.sleep(Math.random() * 3))
yield* Effect.yieldNow
yield* Effect.promise(() => Promise.resolve())
}
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const results = await Promise.all(
dirs.map((d) =>
Instance.provide({
directory: d.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
),
)
for (let i = 0; i < N; i++) {
expect(results[i]).toBe(dirs[i].path)
const a = yield* access(state, dir)
yield* Effect.promise(() => Instance.reload({ directory: dir }))
const b = yield* access(state, dir)
expect(a).not.toBe(b)
expect(seen).toEqual(["1"])
}),
)
it.live("InstanceState invalidates on disposeAll", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
const seen: string[] = []
const state = yield* InstanceState.make((ctx) =>
Effect.acquireRelease(
Effect.sync(() => ({ dir: ctx.directory })),
(value) =>
Effect.sync(() => {
seen.push(value.dir)
}),
),
)
yield* access(state, one)
yield* access(state, two)
yield* Effect.promise(() => Instance.disposeAll())
expect(seen.sort()).toEqual([one, two].sort())
}),
)
it.live("InstanceState.get reads the current directory lazily", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
interface Api {
readonly get: () => Effect.Effect<string>
}
} finally {
await rt.dispose()
for (const d of dirs) await d[Symbol.asyncDispose]()
}
})
test("InstanceState correct after interleaved init and dispose", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
class Test extends Context.Service<Test, Api>()("@test/InstanceStateLazy") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
const get = InstanceState.get(state)
interface Api {
readonly get: () => Effect.Effect<string>
}
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* get
}),
})
}),
)
}
class Test extends Context.Service<Test, Api>()("@test/InterleavedDispose") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.promise(async () => {
await Bun.sleep(5) // slow init
return ctx.directory
}),
)
yield* Effect.gen(function* () {
const a = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one))
const b = yield* Test.use((svc) => svc.get()).pipe(provideInstance(two))
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* InstanceState.get(state)
}),
})
expect(a).toBe(one)
expect(b).toBe(two)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState preserves directory across async boundaries", () =>
Effect.gen(function* () {
const one = yield* tmpdirGitScoped
const two = yield* tmpdirGitScoped
const three = yield* tmpdirGitScoped
interface Api {
readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
}
class Test extends Context.Service<Test, Api>()("@test/InstanceStateAsync") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.sync(() => ({
directory: ctx.directory,
worktree: ctx.worktree,
project: ctx.project.id,
})),
)
return Test.of({
get: Effect.fn("Test.get")(function* () {
yield* Effect.promise(() => Bun.sleep(1))
yield* Effect.sleep(Duration.millis(1))
for (let i = 0; i < 100; i++) {
yield* Effect.yieldNow
}
for (let i = 0; i < 100; i++) {
yield* Effect.promise(() => Promise.resolve())
}
yield* Effect.sleep(Duration.millis(2))
yield* Effect.promise(() => Bun.sleep(1))
return yield* InstanceState.get(state)
}),
})
}),
)
}
yield* Effect.gen(function* () {
const [a, b, c] = yield* Effect.all(
[one, two, three].map((dir) => Test.use((svc) => svc.get()).pipe(provideInstance(dir))),
{ concurrency: "unbounded" },
)
expect(a).toEqual({ directory: one, worktree: one, project: a.project })
expect(b).toEqual({ directory: two, worktree: two, project: b.project })
expect(c).toEqual({ directory: three, worktree: three, project: c.project })
expect(a.project).not.toBe(b.project)
expect(a.project).not.toBe(c.project)
expect(b.project).not.toBe(c.project)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState survives high-contention concurrent access", () =>
Effect.gen(function* () {
const dirs = yield* Effect.all(
Array.from({ length: 20 }, () => tmpdirScoped()),
{ concurrency: "unbounded" },
)
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/HighContention") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* () {
for (let i = 0; i < 10; i++) {
yield* Effect.promise(() => Bun.sleep(Math.random() * 3))
yield* Effect.yieldNow
yield* Effect.promise(() => Promise.resolve())
}
return yield* InstanceState.get(state)
}),
})
}),
)
}
yield* Effect.gen(function* () {
const results = yield* Effect.all(
dirs.map((dir) => Test.use((svc) => svc.get()).pipe(provideInstance(dir))),
{ concurrency: "unbounded" },
)
expect(results).toEqual(dirs)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState correct after interleaved init and dispose", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/InterleavedDispose") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.promise(async () => {
await Bun.sleep(5)
return ctx.directory
}),
)
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* InstanceState.get(state)
}),
})
}),
)
}
yield* Effect.gen(function* () {
const a = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one))
expect(a).toBe(one)
const [, b] = yield* Effect.all(
[
Effect.promise(() => Instance.reload({ directory: one })),
Test.use((svc) => svc.get()).pipe(provideInstance(two)),
],
{ concurrency: "unbounded" },
)
expect(b).toBe(two)
const c = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one))
expect(c).toBe(one)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState mutation in one directory does not leak to another", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
const s1 = yield* access(state, one)
s1.count = 42
const s2 = yield* access(state, two)
expect(s2.count).toBe(0)
const s1again = yield* access(state, one)
expect(s1again.count).toBe(42)
expect(s1again).toBe(s1)
}),
)
it.live("InstanceState dedupes concurrent lookups", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
let n = 0
const state = yield* InstanceState.make(() =>
Effect.promise(async () => {
n += 1
await Bun.sleep(10)
return { n }
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
const [a, b] = yield* Effect.all([access(state, dir), access(state, dir)], { concurrency: "unbounded" })
expect(a).toBe(b)
expect(n).toBe(1)
}),
)
try {
// Init both directories
const a = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(a).toBe(one.path)
it.live("InstanceState survives deferred resume from the same instance context", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
// Dispose one directory, access the other concurrently
const [, b] = await Promise.all([
Instance.reload({ directory: one.path }),
Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
])
expect(b).toBe(two.path)
// Re-access disposed directory - should get fresh state
const c = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(c).toBe(one.path)
} finally {
await rt.dispose()
}
})
test("InstanceState mutation in one directory does not leak to another", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
// Mutate state in directory one
const s1 = yield* Effect.promise(() => access(state, one.path))
s1.count = 42
// Access directory two — should be independent
const s2 = yield* Effect.promise(() => access(state, two.path))
expect(s2.count).toBe(0)
// Confirm directory one still has the mutation
const s1again = yield* Effect.promise(() => access(state, one.path))
expect(s1again.count).toBe(42)
expect(s1again).toBe(s1) // same reference
}),
),
)
})
test("InstanceState dedupes concurrent lookups", async () => {
await using tmp = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() =>
Effect.promise(async () => {
n += 1
await Bun.sleep(10)
return { n }
}),
)
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
expect(a).toBe(b)
expect(n).toBe(1)
}),
),
)
})
test("InstanceState survives deferred resume from the same instance context", async () => {
await using tmp = await tmpdir({ git: true })
interface Api {
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/DeferredResume") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
yield* Deferred.await(gate)
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const gate = await Effect.runPromise(Deferred.make<void>())
const fiber = await Instance.provide({
directory: tmp.path,
fn: () => Promise.resolve(rt.runFork(Test.use((svc) => svc.get(gate)))),
})
await Instance.provide({
directory: tmp.path,
fn: () => Effect.runPromise(Deferred.succeed(gate, void 0)),
})
const exit = await Effect.runPromise(Fiber.await(fiber))
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) {
expect(exit.value).toBe(tmp.path)
interface Api {
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
}
} finally {
await rt.dispose()
}
})
test("InstanceState survives deferred resume outside ALS when InstanceRef is set", async () => {
await using tmp = await tmpdir({ git: true })
class Test extends Context.Service<Test, Api>()("@test/DeferredResume") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
interface Api {
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/DeferredResumeOutside") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
yield* Deferred.await(gate)
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const gate = await Effect.runPromise(Deferred.make<void>())
// Provide InstanceRef so the fiber carries the context even when
// the deferred is resolved from outside Instance.provide ALS.
const fiber = await Instance.provide({
directory: tmp.path,
fn: () =>
Promise.resolve(
rt.runFork(Test.use((svc) => svc.get(gate)).pipe(Effect.provideService(InstanceRef, Instance.current))),
),
})
// Resume from outside any Instance.provide — ALS is NOT set here
await Effect.runPromise(Deferred.succeed(gate, void 0))
const exit = await Effect.runPromise(Fiber.await(fiber))
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) {
expect(exit.value).toBe(tmp.path)
return Test.of({
get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
yield* Deferred.await(gate)
return yield* InstanceState.get(state)
}),
})
}),
)
}
} finally {
await rt.dispose()
}
})
yield* Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstance(dir), Effect.forkScoped)
yield* Deferred.succeed(gate, undefined).pipe(provideInstance(dir))
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState survives deferred resume outside ALS when InstanceRef is set", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
interface Api {
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/DeferredResumeOutside") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
yield* Deferred.await(gate)
return yield* InstanceState.get(state)
}),
})
}),
)
}
yield* Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstance(dir), Effect.forkScoped)
yield* Deferred.succeed(gate, undefined)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir)
}).pipe(Effect.provide(Test.layer))
}),
)
@@ -1,46 +1,49 @@
import { expect, test } from "bun:test"
import { expect } from "bun:test"
import { Effect, Layer, Context } from "effect"
import { makeRuntime } from "../../src/effect/run-service"
import { it } from "../lib/effect"
class Shared extends Context.Service<Shared, { readonly id: number }>()("@test/Shared") {}
test("makeRuntime shares dependent layers through the shared memo map", async () => {
let n = 0
it.live("makeRuntime shares dependent layers through the shared memo map", () =>
Effect.gen(function* () {
let n = 0
const shared = Layer.effect(
Shared,
Effect.sync(() => {
n += 1
return Shared.of({ id: n })
}),
)
const shared = Layer.effect(
Shared,
Effect.sync(() => {
n += 1
return Shared.of({ id: n })
}),
)
class One extends Context.Service<One, { readonly get: () => Effect.Effect<number> }>()("@test/One") {}
const one = Layer.effect(
One,
Effect.gen(function* () {
const svc = yield* Shared
return One.of({
get: Effect.fn("One.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
class One extends Context.Service<One, { readonly get: () => Effect.Effect<number> }>()("@test/One") {}
const one = Layer.effect(
One,
Effect.gen(function* () {
const svc = yield* Shared
return One.of({
get: Effect.fn("One.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
class Two extends Context.Service<Two, { readonly get: () => Effect.Effect<number> }>()("@test/Two") {}
const two = Layer.effect(
Two,
Effect.gen(function* () {
const svc = yield* Shared
return Two.of({
get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
class Two extends Context.Service<Two, { readonly get: () => Effect.Effect<number> }>()("@test/Two") {}
const two = Layer.effect(
Two,
Effect.gen(function* () {
const svc = yield* Shared
return Two.of({
get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
const { runPromise: runOne } = makeRuntime(One, one)
const { runPromise: runTwo } = makeRuntime(Two, two)
const { runPromise: runOne } = makeRuntime(One, one)
const { runPromise: runTwo } = makeRuntime(Two, two)
expect(await runOne((svc) => svc.get())).toBe(1)
expect(await runTwo((svc) => svc.get())).toBe(1)
expect(n).toBe(1)
})
expect(yield* Effect.promise(() => runOne((svc) => svc.get()))).toBe(1)
expect(yield* Effect.promise(() => runTwo((svc) => svc.get()))).toBe(1)
expect(n).toBe(1)
}),
)
+44 -50
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect"
import { Runner } from "@/effect/runner"
import { it } from "../lib/effect"
@@ -198,58 +198,52 @@ describe("Runner", () => {
}),
)
test("cancel does not deadlock when replacement work starts before interrupted run exits", async () => {
function defer() {
let resolve!: () => void
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
it.live(
"cancel does not deadlock when replacement work starts before interrupted run exits",
Effect.gen(function* () {
const s = yield* Scope.Scope
const hit = yield* Deferred.make<void>()
const hold = yield* Deferred.make<void>()
const done = yield* Deferred.make<void>()
function fail(ms: number, msg: string) {
return new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(msg)), ms)
})
}
yield* Effect.gen(function* () {
const runner = Runner.make<string>(s)
const first = Effect.never.pipe(
Effect.onInterrupt(() => Deferred.succeed(hit, undefined)),
Effect.ensuring(Deferred.await(hold)),
Effect.as("first"),
)
const s = await Effect.runPromise(Scope.make())
const hit = defer()
const hold = defer()
const done = defer()
try {
const runner = Runner.make<string>(s)
const first = Effect.never.pipe(
Effect.onInterrupt(() => Effect.sync(() => hit.resolve())),
Effect.ensuring(Effect.promise(() => hold.promise)),
Effect.as("first"),
const a = yield* runner.ensureRunning(first).pipe(Effect.exit, Effect.forkChild)
yield* Effect.sleep("10 millis")
const stop = yield* runner.cancel.pipe(Effect.forkChild)
yield* Deferred.await(hit).pipe(Effect.timeout("250 millis"))
const b = yield* runner.ensureRunning(Deferred.await(done).pipe(Effect.as("second"))).pipe(Effect.forkChild)
yield* Effect.yieldNow
expect(runner.busy).toBe(true)
yield* Deferred.succeed(hold, undefined)
const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("250 millis"))
expect(Exit.isSuccess(stopExit)).toBe(true)
expect(runner.busy).toBe(true)
yield* Deferred.succeed(done, undefined)
expect(yield* Fiber.join(b).pipe(Effect.timeout("250 millis"))).toBe("second")
expect(runner.busy).toBe(false)
const exit = yield* Fiber.join(a)
expect(Exit.isFailure(exit)).toBe(true)
}).pipe(
Effect.ensuring(
Effect.all([Deferred.succeed(hold, undefined), Deferred.succeed(done, undefined)], { discard: true }).pipe(
Effect.ignore,
),
),
)
const a = Effect.runPromiseExit(runner.ensureRunning(first))
await Bun.sleep(10)
const stop = Effect.runPromise(runner.cancel)
await Promise.race([hit.promise, fail(250, "cancel did not interrupt running work")])
const b = Effect.runPromise(runner.ensureRunning(Effect.promise(() => done.promise).pipe(Effect.as("second"))))
expect(runner.busy).toBe(true)
hold.resolve()
await Promise.race([stop, fail(250, "cancel deadlocked while replacement run was active")])
expect(runner.busy).toBe(true)
done.resolve()
expect(await b).toBe("second")
expect(runner.busy).toBe(false)
const exit = await a
expect(Exit.isFailure(exit)).toBe(true)
} finally {
hold.resolve()
done.resolve()
await Promise.race([Effect.runPromise(Scope.close(s, Exit.void)), fail(1000, "runner scope did not close")])
}
})
}),
)
// --- shell semantics ---
+64 -78
View File
@@ -1,18 +1,18 @@
import { afterAll, afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { afterAll, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
const { Plugin } = await import("../../src/plugin/index")
const { Instance } = await import("../../src/project/instance")
afterEach(async () => {
await Instance.disposeAll()
})
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer))
const systemHook = "experimental.chat.system.transform"
afterAll(() => {
if (disableDefault === undefined) {
@@ -22,95 +22,81 @@ afterAll(() => {
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
})
async function project(source: string) {
return tmpdir({
init: async (dir) => {
function withProject<A, E, R>(source: string, self: Effect.Effect<A, E, R>) {
return provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "plugin.ts")
await Bun.write(file, source)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(file).href],
},
null,
2,
),
yield* Effect.all(
[
Effect.promise(() => Bun.write(file, source)),
Effect.promise(() =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(file).href],
},
null,
2,
),
),
),
],
{ discard: true, concurrency: 2 },
)
},
})
return yield* self
}),
)
}
const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransform")(function* () {
const plugin = yield* Plugin.Service
const out = { system: [] as string[] }
yield* plugin.trigger(
systemHook,
{
model: {
providerID: ProviderID.anthropic,
modelID: ModelID.make("claude-sonnet-4-6"),
},
},
out,
)
return out.system
})
describe("plugin.trigger", () => {
test("runs synchronous hooks without crashing", async () => {
await using tmp = await project(
it.live("runs synchronous hooks without crashing", () =>
withProject(
[
"export default async () => ({",
' "experimental.chat.system.transform": (_input, output) => {',
` ${JSON.stringify(systemHook)}: (_input, output) => {`,
' output.system.unshift("sync")',
" },",
"})",
"",
].join("\n"),
)
Effect.gen(function* () {
expect(yield* triggerSystemTransform()).toEqual(["sync"])
}),
),
)
const out = await Instance.provide({
directory: tmp.path,
fn: async () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const out = { system: [] as string[] }
yield* plugin.trigger(
"experimental.chat.system.transform",
{
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
} as any,
},
out,
)
return out
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
})
expect(out.system).toEqual(["sync"])
})
test("awaits asynchronous hooks", async () => {
await using tmp = await project(
it.live("awaits asynchronous hooks", () =>
withProject(
[
"export default async () => ({",
' "experimental.chat.system.transform": async (_input, output) => {',
` ${JSON.stringify(systemHook)}: async (_input, output) => {`,
" await Bun.sleep(1)",
' output.system.unshift("async")',
" },",
"})",
"",
].join("\n"),
)
const out = await Instance.provide({
directory: tmp.path,
fn: async () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const out = { system: [] as string[] }
yield* plugin.trigger(
"experimental.chat.system.transform",
{
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
} as any,
},
out,
)
return out
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
})
expect(out.system).toEqual(["async"])
})
Effect.gen(function* () {
expect(yield* triggerSystemTransform()).toEqual(["async"])
}),
),
)
})
@@ -1,8 +1,10 @@
import { afterAll, afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { afterAll, afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
@@ -11,6 +13,7 @@ const { Flag } = await import("@opencode-ai/core/flag/flag")
const { Plugin } = await import("../../src/plugin/index")
const { Workspace } = await import("../../src/control-plane/workspace")
const { Instance } = await import("../../src/project/instance")
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, Workspace.defaultLayer, CrossSpawnSpawner.defaultLayer))
const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
@@ -31,79 +34,76 @@ afterAll(() => {
})
describe("plugin.workspace", () => {
test("plugin can install a workspace adaptor", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
it.live("plugin can install a workspace adaptor", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const type = `plug-${Math.random().toString(36).slice(2)}`
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "created.json")
const space = path.join(dir, "space")
await Bun.write(
file,
[
"export default async ({ experimental_workspace }) => {",
` experimental_workspace.register(${JSON.stringify(type)}, {`,
' name: "plug",',
' description: "plugin workspace adaptor",',
" configure(input) {",
` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`,
" },",
" async create(input) {",
` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`,
" },",
" async remove() {},",
" target(input) {",
' return { type: "local", directory: input.directory }',
" },",
" })",
" return {}",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(file).href],
},
null,
2,
yield* Effect.promise(() =>
Bun.write(
file,
[
"export default async ({ experimental_workspace }) => {",
` experimental_workspace.register(${JSON.stringify(type)}, {`,
' name: "plug",',
' description: "plugin workspace adaptor",',
" configure(input) {",
` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`,
" },",
" async create(input) {",
` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`,
" },",
" async remove() {},",
" target(input) {",
' return { type: "local", directory: input.directory }',
" },",
" })",
" return {}",
"}",
"",
].join("\n"),
),
)
return { mark, space, type }
},
})
yield* Effect.promise(() =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(file).href],
},
null,
2,
),
),
)
const info = await Instance.provide({
directory: tmp.path,
fn: async () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
yield* plugin.init()
return Workspace.create({
type: tmp.extra.type,
branch: null,
extra: { key: "value" },
projectID: Instance.project.id,
})
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
})
const plugin = yield* Plugin.Service
yield* plugin.init()
const workspace = yield* Workspace.Service
const info = yield* workspace.create({
type,
branch: null,
extra: { key: "value" },
projectID: Instance.project.id,
})
expect(info.type).toBe(tmp.extra.type)
expect(info.name).toBe("plug")
expect(info.branch).toBe("plug/main")
expect(info.directory).toBe(tmp.extra.space)
expect(info.extra).toEqual({ key: "value" })
expect(JSON.parse(await Bun.file(tmp.extra.mark).text())).toMatchObject({
type: tmp.extra.type,
name: "plug",
branch: "plug/main",
directory: tmp.extra.space,
extra: { key: "value" },
})
})
expect(info.type).toBe(type)
expect(info.name).toBe("plug")
expect(info.branch).toBe("plug/main")
expect(info.directory).toBe(space)
expect(info.extra).toEqual({ key: "value" })
expect(JSON.parse(yield* Effect.promise(() => Bun.file(mark).text()))).toMatchObject({
type,
name: "plug",
branch: "plug/main",
directory: space,
extra: { key: "value" },
})
}),
),
)
})
@@ -0,0 +1,103 @@
import { NodeHttpServer } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import {
Authorization,
ServerAuthConfig,
authorizationLayer,
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { testEffect } from "../lib/effect"
const Api = HttpApi.make("test-authorization").add(
HttpApiGroup.make("test")
.add(
HttpApiEndpoint.get("probe", "/probe", {
success: Schema.String,
}),
)
.middleware(Authorization),
)
const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok")))
const apiLayer = HttpRouter.serve(
HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)),
{ disableListenLog: true, disableLogger: true },
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" })
const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" })
const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer)))
const basic = (username: string, password: string) =>
`Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64")
const getProbe = (headers?: Record<string, string>) =>
HttpClientRequest.get("/probe").pipe(
headers ? HttpClientRequest.setHeaders(headers) : (request) => request,
HttpClient.execute,
)
describe("HttpApi authorization middleware", () => {
it.live("allows requests when server password is not configured", () =>
Effect.gen(function* () {
const response = yield* getProbe()
expect(response.status).toBe(200)
expect(yield* response.json).toBe("ok")
}),
)
itSecret.live("requires configured password for basic auth", () =>
Effect.gen(function* () {
const [missing, badPassword, good] = yield* Effect.all(
[
getProbe(),
getProbe({ authorization: basic("opencode", "wrong") }),
getProbe({ authorization: basic("opencode", "secret") }),
],
{ concurrency: "unbounded" },
)
expect(missing.status).toBe(401)
expect(badPassword.status).toBe(401)
expect(good.status).toBe(200)
}),
)
itKitSecret.live("respects configured basic auth username", () =>
Effect.gen(function* () {
const [defaultUser, configuredUser] = yield* Effect.all(
[getProbe({ authorization: basic("opencode", "secret") }), getProbe({ authorization: basic("kit", "secret") })],
{ concurrency: "unbounded" },
)
expect(defaultUser.status).toBe(401)
expect(configuredUser.status).toBe(200)
}),
)
itSecret.live("accepts auth token query credentials", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get(`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
expect(response.status).toBe(200)
}),
)
itSecret.live("rejects malformed auth token query credentials", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get("/probe?auth_token=not-base64")
expect(response.status).toBe(401)
}),
)
})
@@ -2,11 +2,14 @@ import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { OpenApi } from "effect/unstable/httpapi"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
@@ -30,7 +33,26 @@ function app(input?: { password?: string; username?: string }) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_SERVER_PASSWORD = input?.password
Flag.OPENCODE_SERVER_USERNAME = input?.username
return Server.Default().app
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input?.password,
OPENCODE_SERVER_USERNAME: input?.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input: string | URL | Request, init?: RequestInit) {
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
},
}
}
function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
@@ -94,9 +116,9 @@ type RequestBody = {
required?: boolean
}
function parameterKey(param: unknown) {
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return
if (typeof param.in !== "string" || typeof param.name !== "string") return
function parameterKey(param: unknown): string | undefined {
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
return `${param.in}:${param.name}:${"required" in param && param.required === true}`
}
@@ -105,27 +127,29 @@ function parameterSchema(input: {
path: string
method: (typeof methods)[number]
name: string
}) {
}): unknown {
const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find(
(param) => !!param && typeof param === "object" && "name" in param && param.name === input.name,
)
if (!param || typeof param !== "object" || !("schema" in param)) return
if (!param || typeof param !== "object" || !("schema" in param)) return undefined
return param.schema
}
function requestBodyKey(spec: OpenApiSpec, body: unknown) {
if (!body || typeof body !== "object" || !("content" in body)) return ""
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset.
const requestBody = body as RequestBody
return JSON.stringify({
required: requestBody.required === true,
content: Object.entries(requestBody.content ?? {})
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)])
.sort(),
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const)
.sort(([left], [right]) => left.localeCompare(right)),
})
}
function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) {
if (!schema) return ""
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper.
const resolved = (
schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema
) as OpenApiSchema | undefined
@@ -142,6 +166,7 @@ function responseContentTypes(input: {
}) {
const responses = input.spec.paths[input.path]?.[input.method]?.responses
if (!responses || typeof responses !== "object" || !(input.status in responses)) return []
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup.
const response = (responses as Record<string, unknown>)[input.status]
if (!response || typeof response !== "object" || !("content" in response)) return []
const content = (response as { content?: unknown }).content
@@ -0,0 +1,64 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test"
import { Config, Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { resetDatabase } from "../fixture/db"
import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
}
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_SERVER_PASSWORD = "secret"
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
await resetDatabase()
}),
)
}),
)
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
ExperimentalHttpApiServer.routes,
{ disableListenLog: true, disableLogger: true },
)
const it = testEffect(
Layer.mergeAll(
testStateLayer,
servedRoutes.pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
),
),
)
describe("HttpApi CORS", () => {
it.live("allows browser preflight requests without credentials", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.options(InstancePaths.path).pipe(
HttpClientRequest.setHeaders({
origin: "http://localhost:3000",
"access-control-request-method": "GET",
"access-control-request-headers": "authorization",
}),
HttpClient.execute,
)
expect(response.status).toBe(204)
expect(response.headers["access-control-allow-origin"]).toBe("http://localhost:3000")
expect(response.headers["access-control-allow-headers"]).toBe("authorization")
}),
)
})
@@ -11,9 +11,9 @@ void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
function app(experimental = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
async function readFirstChunk(response: Response) {
@@ -45,4 +45,13 @@ describe("event HttpApi bridge", () => {
expect(response.headers.get("x-content-type-options")).toBe("nosniff")
expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n')
})
test("matches legacy first event frame", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
const legacy = await app(false).request(EventPaths.event, { headers })
const effect = await app(true).request(EventPaths.event, { headers })
expect(await readFirstChunk(effect)).toBe(await readFirstChunk(legacy))
})
})
@@ -1,7 +1,8 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Effect, Fiber, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises"
@@ -12,6 +13,7 @@ import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
@@ -34,7 +36,13 @@ const testStateLayer = Layer.effectDiscard(
)
const it = testEffect(
Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, Project.defaultLayer),
Layer.mergeAll(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
Project.defaultLayer,
Workspace.defaultLayer,
),
)
const instanceContextTestLayer = instanceRouterMiddleware
@@ -54,16 +62,17 @@ const localAdaptor = (directory: string): WorkspaceAdaptor => ({
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
Effect.acquireRelease(
Effect.promise(async () => {
Effect.gen(function* () {
registerAdaptor(input.projectID, input.type, localAdaptor(input.directory))
return Workspace.create({
const workspace = yield* Workspace.Service
return yield* workspace.create({
type: input.type,
branch: null,
extra: null,
projectID: input.projectID,
})
}),
(workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore),
(info) => Workspace.Service.use((workspace) => workspace.remove(info.id)).pipe(Effect.ignore),
)
const probeInstanceContext = Effect.gen(function* () {
@@ -84,6 +93,40 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
Layer.build,
)
const waitDisposedEvent = Effect.promise(
() =>
new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed") return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve({ directory: event.directory, workspace: event.workspace })
}
GlobalBus.on("event", onEvent)
}),
)
const serveDisposeProbe = () =>
HttpRouter.serve(
HttpRouter.add(
"POST",
"/dispose-probe",
Effect.gen(function* () {
const instance = yield* InstanceRef
if (!instance) return HttpServerResponse.empty({ status: 500 })
yield* markInstanceForDisposal(instance)
return yield* HttpServerResponse.json(true)
}),
).pipe(Layer.provide(instanceContextTestLayer)),
{ middleware: disposeMiddleware, disableListenLog: true, disableLogger: true },
).pipe(Layer.build)
describe("HttpApi instance context middleware", () => {
it.live("provides instance context from the routed directory", () =>
Effect.gen(function* () {
@@ -164,4 +207,27 @@ describe("HttpApi instance context middleware", () => {
})
}),
)
it.live("preserves selected workspace id on instance disposal events", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceDir = path.join(dir, ".workspace-local")
const workspace = yield* createLocalWorkspace({
projectID: project.project.id,
type: "instance-context-dispose-event",
directory: workspaceDir,
})
yield* serveDisposeProbe()
const disposed = yield* waitDisposedEvent.pipe(Effect.forkScoped)
const response = yield* HttpClientRequest.post(`/dispose-probe?workspace=${workspace.id}`).pipe(
HttpClient.execute,
)
expect(response.status).toBe(200)
expect(yield* response.json).toBe(true)
expect(yield* Fiber.join(disposed)).toEqual({ directory: workspaceDir, workspace: workspace.id })
}),
)
})
@@ -0,0 +1,138 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
}
async function waitDisposed(directory: string) {
return await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
})
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await resetDatabase()
})
describe("instance HttpApi", () => {
test("serves catalog read endpoints through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const [commands, agents, skills, lsp, formatter] = await Promise.all([
app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }),
])
expect(commands.status).toBe(200)
expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" }))
expect(agents.status).toBe(200)
expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" }))
expect(skills.status).toBe(200)
expect(await skills.json()).toBeArray()
expect(lsp.status).toBe(200)
expect(await lsp.json()).toEqual([])
expect(formatter.status).toBe(200)
expect(await formatter.json()).toEqual([])
})
test("serves project git init through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const disposed = waitDisposed(tmp.path)
const response = await app().request("/project/git/init", {
method: "POST",
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
await disposed
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
expect(current.status).toBe(200)
expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
})
test("serves project update through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
expect(current.status).toBe(200)
const project = (await current.json()) as { id: string }
const response = await app().request(`/project/${project.id}`, {
method: "PATCH",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }),
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
id: project.id,
name: "patched-project",
commands: { start: "bun dev" },
})
const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } })
expect(list.status).toBe(200)
expect(await list.json()).toContainEqual(
expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }),
)
})
test("serves instance dispose through Hono bridge", async () => {
await using tmp = await tmpdir()
const disposed = new Promise<string | undefined>((resolve) => {
const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
if (event.payload.type !== "server.instance.disposed") return
GlobalBus.off("event", onEvent)
resolve(event.directory)
}
GlobalBus.on("event", onEvent)
})
const response = await app().request(InstancePaths.dispose, {
method: "POST",
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toBe(true)
expect(await disposed).toBe(tmp.path)
})
})
@@ -1,164 +1,83 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { describe, expect } from "bun:test"
import { Config, Effect, FileSystem, Layer, Path } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import * as Log from "@opencode-ai/core/util/log"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
// Flip the experimental HttpApi flag so backend selection telemetry on the
// production routes reports the right backend, and reset the database around
// the test so per-instance state does not leak between runs. resetDatabase()
// already calls Instance.disposeAll(), so we don't repeat it.
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
await resetDatabase()
}),
)
}),
)
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
// Mount the production HttpApi route tree on a real Node HTTP server bound to
// 127.0.0.1:0 and a fetch-based HttpClient that prepends the server URL. This
// keeps the test wired through the same route layer production uses, without
// going through Server.Default()/Hono.
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
ExperimentalHttpApiServer.routes,
{ disableListenLog: true, disableLogger: true },
)
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
}
const httpApiServerLayer = servedRoutes.pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
)
async function waitDisposed(directory: string) {
return await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer))
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
})
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await resetDatabase()
})
const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
describe("instance HttpApi", () => {
test("serves path and VCS read endpoints through Hono bridge", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(path.join(tmp.path, "changed.txt"), "hello")
it.live("serves path and VCS read endpoints", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* fs.writeFileString(path.join(dir, "changed.txt"), "hello")
const vcsDiff = new URL(`http://localhost${InstancePaths.vcsDiff}`)
vcsDiff.searchParams.set("mode", "git")
const [paths, vcs, diff] = yield* Effect.all(
[
HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute),
HttpClientRequest.get(InstancePaths.vcs).pipe(directoryHeader(dir), HttpClient.execute),
HttpClientRequest.get(InstancePaths.vcsDiff).pipe(
HttpClientRequest.setUrlParam("mode", "git"),
directoryHeader(dir),
HttpClient.execute,
),
],
{ concurrency: "unbounded" },
)
const [paths, vcs, diff] = await Promise.all([
app().request(InstancePaths.path, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.vcs, { headers: { "x-opencode-directory": tmp.path } }),
app().request(vcsDiff, { headers: { "x-opencode-directory": tmp.path } }),
])
expect(paths.status).toBe(200)
expect(yield* paths.json).toMatchObject({ directory: dir, worktree: dir })
expect(paths.status).toBe(200)
expect(await paths.json()).toMatchObject({ directory: tmp.path, worktree: tmp.path })
expect(vcs.status).toBe(200)
expect(yield* vcs.json).toMatchObject({ branch: expect.any(String) })
expect(vcs.status).toBe(200)
expect(await vcs.json()).toMatchObject({ branch: expect.any(String) })
expect(diff.status).toBe(200)
expect(await diff.json()).toContainEqual(
expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
)
})
test("serves catalog read endpoints through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const [commands, agents, skills, lsp, formatter] = await Promise.all([
app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }),
])
expect(commands.status).toBe(200)
expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" }))
expect(agents.status).toBe(200)
expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" }))
expect(skills.status).toBe(200)
expect(await skills.json()).toBeArray()
expect(lsp.status).toBe(200)
expect(await lsp.json()).toEqual([])
expect(formatter.status).toBe(200)
expect(await formatter.json()).toEqual([])
})
test("serves project git init through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const disposed = waitDisposed(tmp.path)
const response = await app().request("/project/git/init", {
method: "POST",
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
await disposed
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
expect(current.status).toBe(200)
expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
})
test("serves project update through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
expect(current.status).toBe(200)
const project = (await current.json()) as { id: string }
const response = await app().request(`/project/${project.id}`, {
method: "PATCH",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }),
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
id: project.id,
name: "patched-project",
commands: { start: "bun dev" },
})
const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } })
expect(list.status).toBe(200)
expect(await list.json()).toContainEqual(
expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }),
)
})
test("serves instance dispose through Hono bridge", async () => {
await using tmp = await tmpdir()
const disposed = new Promise<string | undefined>((resolve) => {
const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
if (event.payload.type !== "server.instance.disposed") return
GlobalBus.off("event", onEvent)
resolve(event.directory)
}
GlobalBus.on("event", onEvent)
})
const response = await app().request(InstancePaths.dispose, {
method: "POST",
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toBe(true)
expect(await disposed).toBe(tmp.path)
})
expect(diff.status).toBe(200)
expect(yield* diff.json).toContainEqual(
expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
)
}),
)
})
@@ -0,0 +1,76 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Session } from "@/session/session"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"
import { McpApi, McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
import { Authorization } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRouteContext,
WorkspaceRoutingMiddleware,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { testEffect } from "../lib/effect"
const TestHttpApi = HttpApi.make("opencode-instance").addHttpApi(McpApi)
const fakeSession = Layer.mock(Session.Service)({})
const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) =>
Effect.succeed(
handlers
.handle("status", () => Effect.die("unexpected MCP status"))
.handle("add", () => Effect.die("unexpected MCP add"))
.handle("authStart", () =>
Effect.succeed({ authorizationUrl: "https://auth.example/start", oauthState: "state-123" }),
)
.handle("authCallback", () => Effect.die("unexpected MCP authCallback"))
.handle("authAuthenticate", () => Effect.die("unexpected MCP authAuthenticate"))
.handle("authRemove", () => Effect.die("unexpected MCP authRemove"))
.handle("connect", () => Effect.die("unexpected MCP connect"))
.handle("disconnect", () => Effect.die("unexpected MCP disconnect")),
),
)
const passthroughAuthorization = Layer.succeed(
Authorization,
Authorization.of({
basic: (effect) => effect,
authToken: (effect) => effect,
}),
)
const passthroughInstanceContext = Layer.succeed(
InstanceContextMiddleware,
InstanceContextMiddleware.of((effect) => effect),
)
const testWorkspaceRouting = Layer.succeed(
WorkspaceRoutingMiddleware,
WorkspaceRoutingMiddleware.of((effect) =>
effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory: process.cwd() }))),
),
)
const it = testEffect(
HttpRouter.serve(
HttpApiBuilder.layer(TestHttpApi).pipe(
Layer.provide(testMcpHandlers),
Layer.provide([passthroughAuthorization, passthroughInstanceContext, testWorkspaceRouting, fakeSession]),
),
{ disableListenLog: true, disableLogger: true },
).pipe(Layer.provideMerge(NodeHttpServer.layerTest)),
)
describe("mcp HttpApi OAuth", () => {
it.live("preserves oauth state when starting OAuth", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.post(McpPaths.auth.replace(":name", "demo")).pipe(HttpClient.execute)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({
authorizationUrl: "https://auth.example/start",
oauthState: "state-123",
})
}),
)
})
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { handlePtyInput } from "../../src/pty/input"
describe("pty HttpApi websocket input", () => {
test("does not forward invalid binary frames to the PTY handler", async () => {
const messages: Array<string | ArrayBuffer> = []
const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) }
await Effect.runPromise(handlePtyInput(handler, "ready"))
await Effect.runPromise(handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd])))
await Effect.runPromise(handlePtyInput(handler, new TextEncoder().encode("hello")))
expect(messages).toEqual(["ready", "hello"])
})
})
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { PtyID } from "../../src/pty/schema"
import { Instance } from "../../src/project/instance"
@@ -6,18 +7,60 @@ import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { tmpdir, tmpdirScoped } from "../fixture/fixture"
import { Config, Effect, Layer, Queue, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Pty } from "../../src/pty"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const testPty = process.platform === "win32" ? test.skip : test
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await resetDatabase()
}),
)
}),
)
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
ExperimentalHttpApiServer.routes,
{ disableListenLog: true, disableLogger: true },
)
const effectIt = testEffect(
Layer.mergeAll(
testStateLayer,
Socket.layerWebSocketConstructorGlobal,
servedRoutes.pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
),
),
)
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
}
function serverUrl() {
return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
}
const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
@@ -85,4 +128,48 @@ describe("pty HttpApi bridge", () => {
})
expect(response.status).toBe(404)
})
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
"serves PTY websocket output and input through Effect routes",
() =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
const created = yield* HttpClientRequest.post(PtyPaths.create).pipe(
directoryHeader(dir),
HttpClientRequest.bodyJson({ command: "/bin/cat", title: "websocket" }),
Effect.flatMap(HttpClient.execute),
)
expect(created.status).toBe(200)
const info = yield* Schema.decodeUnknownEffect(Pty.Info)(yield* created.json)
const socket = yield* Socket.makeWebSocket(
`${(yield* serverUrl()).replace(/^http/, "ws")}${PtyPaths.connect.replace(":ptyID", info.id)}?cursor=-1&directory=${encodeURIComponent(dir)}`,
{ closeCodeIsError: () => false },
)
const messages = yield* Queue.unbounded<string>()
yield* socket
.runRaw((message) =>
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
)
.pipe(Effect.catch(() => Effect.void))
.pipe(Effect.forkScoped)
const write = yield* socket.writer
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
Effect.gen(function* () {
const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
if (next.includes(expected)) return next
return yield* takeUntil(expected, next)
})
yield* write("ping-route\n")
expect(yield* takeUntil("ping-route")).toContain("ping-route")
yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe(
directoryHeader(dir),
HttpClient.execute,
)
expect(removed.status).toBe(200)
}),
)
})
@@ -0,0 +1,89 @@
import { afterEach, describe, expect, test } from "bun:test"
import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { PtyID } from "../../src/pty/schema"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import * as Log from "@opencode-ai/core/util/log"
void Log.init({ print: false })
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app(input: { password?: string; username?: string }) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input.password,
OPENCODE_SERVER_USERNAME: input.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input: string | URL | Request, init?: RequestInit) {
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
},
}
}
function basic(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
async function cancelBody(response: Response) {
await response.body?.cancel().catch(() => {})
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
await Instance.disposeAll()
await resetDatabase()
})
describe("HttpApi raw route authorization", () => {
test("requires configured auth before opening the raw instance event stream", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const server = app({ password: "secret" })
const headers = { "x-opencode-directory": tmp.path }
const missing = await server.request(EventPaths.event, { headers })
await cancelBody(missing)
expect(missing.status).toBe(401)
const authed = await server.request(EventPaths.event, {
headers: { ...headers, authorization: basic("opencode", "secret") },
})
await cancelBody(authed)
expect(authed.status).toBe(200)
})
test("requires configured auth before resolving the raw PTY websocket route", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const server = app({ password: "secret" })
const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending())
const headers = { "x-opencode-directory": tmp.path }
const missing = await server.request(route, { headers })
await cancelBody(missing)
expect(missing.status).toBe(401)
const authed = await server.request(route, {
headers: { ...headers, authorization: basic("opencode", "secret") },
})
await cancelBody(authed)
expect(authed.status).toBe(404)
})
})
@@ -1,9 +1,11 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { ConfigProvider, Effect, Layer } from "effect"
import type * as Scope from "effect/Scope"
import { HttpRouter } from "effect/unstable/http"
import { Flag } from "@opencode-ai/core/flag/flag"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Instance } from "../../src/project/instance"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Server } from "../../src/server/server"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
@@ -33,7 +35,27 @@ function app(backend: Backend, input?: { password?: string; username?: string })
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi"
Flag.OPENCODE_SERVER_PASSWORD = input?.password
Flag.OPENCODE_SERVER_USERNAME = input?.username
return backend === "httpapi" ? Server.Default().app : Server.Legacy().app
if (backend === "legacy") return Server.Legacy().app
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input?.password,
OPENCODE_SERVER_USERNAME: input?.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input: string | URL | Request, init?: RequestInit) {
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
},
}
}
function client(
@@ -123,7 +145,7 @@ function firstEvent(open: () => Promise<{ stream: AsyncIterator<unknown> }>) {
}
function record(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
}
function array(value: unknown) {
@@ -382,7 +404,7 @@ describe("HttpApi SDK", () => {
lsp,
}),
project: { worktreeSelected: record(project.data).worktree === directory },
paths: { cwdSelected: record(paths.data).cwd === directory },
paths: { directorySelected: record(paths.data).directory === directory },
file: record(file.data).content,
hasProject: array(projects.data).length > 0,
foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
@@ -1,15 +1,24 @@
import { afterEach, describe, expect } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
import { Database } from "@/storage/db"
import { SessionTable } from "@/session/session.sql"
import * as Log from "@opencode-ai/core/util/log"
import { eq } from "drizzle-orm"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
@@ -17,10 +26,11 @@ import { it } from "../lib/effect"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
function app(experimental = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
@@ -72,10 +82,38 @@ function createTextMessage(directory: string, sessionID: SessionID, text: string
)
}
const localAdaptor = (directory: string): WorkspaceAdaptor => ({
name: "Local Test",
description: "Create a local test workspace",
configure: (info) => ({ ...info, name: "local-test", directory }),
create: async () => {
await mkdir(directory, { recursive: true })
},
async remove() {},
target: () => ({ type: "local" as const, directory }),
})
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
Effect.gen(function* () {
registerAdaptor(input.projectID, input.type, localAdaptor(input.directory))
return yield* Workspace.Service.use((svc) =>
svc.create({
type: input.type,
branch: null,
extra: null,
projectID: input.projectID,
}),
).pipe(Effect.provide(Workspace.defaultLayer))
})
function request(path: string, init?: RequestInit) {
return Effect.promise(async () => app().request(path, init))
}
function requestWithBackend(experimental: boolean, path: string, init?: RequestInit) {
return Effect.promise(async () => app(experimental).request(path, init))
}
function json<T>(response: Response) {
return Effect.promise(async () => {
if (response.status !== 200) throw new Error(await response.text())
@@ -99,6 +137,7 @@ function withTmp<A, E, R>(
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await Instance.disposeAll()
await resetDatabase()
})
@@ -217,6 +256,129 @@ describe("session HttpApi", () => {
),
)
it.live(
"persists selected workspace id when creating a session",
withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const project = yield* Project.use.fromDirectory(tmp.path).pipe(Effect.provide(Project.defaultLayer))
const workspace = yield* createLocalWorkspace({
projectID: project.project.id,
type: "session-create-workspace",
directory: path.join(tmp.path, ".workspace-local"),
})
const created = yield* requestJson<Session.Info>(`${SessionPaths.create}?workspace=${workspace.id}`, {
method: "POST",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
body: JSON.stringify({ title: "workspace session" }),
})
expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id })
expect(
yield* Effect.sync(() =>
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, created.id))
.get(),
),
),
).toEqual({ workspaceID: workspace.id })
}),
),
)
it.live(
"matches legacy archived timestamp validation",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
const legacy = yield* createSession(tmp.path, { title: "legacy" })
const effect = yield* createSession(tmp.path, { title: "effect" })
const body = JSON.stringify({ time: { archived: -1 } })
const legacyResponse = yield* requestWithBackend(
false,
pathFor(SessionPaths.update, { sessionID: legacy.id }),
{
method: "PATCH",
headers,
body,
},
)
expect(legacyResponse.status).toBe(200)
expect((yield* json<Session.Info>(legacyResponse)).time.archived).toBe(-1)
const effectResponse = yield* requestWithBackend(true, pathFor(SessionPaths.update, { sessionID: effect.id }), {
method: "PATCH",
headers,
body,
})
expect(effectResponse.status).toBe(legacyResponse.status)
expect((yield* json<Session.Info>(effectResponse)).time.archived).toBe(-1)
}),
),
)
it.live(
"matches legacy project-scoped path and directory precedence",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const currentDir = path.join(tmp.path, "packages", "opencode", "src")
yield* Effect.promise(() => mkdir(currentDir, { recursive: true }))
const pathSession = yield* createSession(currentDir)
const pathlessSession = yield* createSession(currentDir)
yield* Effect.sync(() =>
Database.use((db) =>
db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, pathlessSession.id)).run(),
),
)
const query = new URLSearchParams({
scope: "project",
path: "packages/opencode/src",
directory: currentDir,
})
const headers = { "x-opencode-directory": tmp.path }
const legacy = (yield* json<Session.Info[]>(
yield* requestWithBackend(false, `${SessionPaths.list}?${query}`, { headers }),
)).map((item) => item.id)
const effect = (yield* json<Session.Info[]>(
yield* requestWithBackend(true, `${SessionPaths.list}?${query}`, { headers }),
)).map((item) => item.id)
expect(legacy).toContain(pathSession.id)
expect(legacy).not.toContain(pathlessSession.id)
expect(effect).toEqual(legacy)
}),
),
)
it.live(
"matches legacy paginated message link headers",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": tmp.path }
const session = yield* createSession(tmp.path, { title: "messages" })
yield* createTextMessage(tmp.path, session.id, "first")
yield* createTextMessage(tmp.path, session.id, "second")
const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1`
const legacy = yield* requestWithBackend(false, route, { headers })
const effect = yield* requestWithBackend(true, route, { headers })
expect(effect.headers.get("x-next-cursor")).toBe(legacy.headers.get("x-next-cursor"))
expect(effect.headers.get("link")).toBe(legacy.headers.get("link"))
expect(effect.headers.get("access-control-expose-headers")).toBe(
legacy.headers.get("access-control-expose-headers"),
)
}),
),
)
it.live(
"serves message mutation routes through Hono bridge",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test } from "bun:test"
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
@@ -24,6 +24,7 @@ function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
}
afterEach(async () => {
mock.restore()
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await Instance.disposeAll()
@@ -35,6 +36,7 @@ describe("sync HttpApi", () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
const info = spyOn(Log.create({ service: "server.sync" }), "info")
const session = await Instance.provide({
directory: tmp.path,
@@ -78,6 +80,8 @@ describe("sync HttpApi", () => {
})
expect(replayed.status).toBe(200)
expect(await replayed.json()).toEqual({ sessionID: session.id })
expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
})
test("matches legacy seq validation", async () => {
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { Context } from "hono"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "../../src/bus/global"
import { TuiEvent } from "../../src/cli/cmd/tui/event"
import { SessionID } from "../../src/session/schema"
import { Instance } from "../../src/project/instance"
import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui"
@@ -15,9 +17,20 @@ void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
function app(experimental = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
function nextCommandExecute() {
return new Promise<unknown>((resolve) => {
const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => {
if (event.payload.type !== TuiEvent.CommandExecute.type) return
GlobalBus.off("event", listener)
resolve(event.payload.properties?.command)
}
GlobalBus.on("event", listener)
})
}
async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) {
@@ -72,6 +85,27 @@ describe("tui HttpApi bridge", () => {
expect(missing.status).toBe(404)
})
test("matches legacy unknown execute command behavior", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
const body = JSON.stringify({ command: "unknown_command" })
const legacyCommand = nextCommandExecute()
const legacy = await app(false).request(TuiPaths.executeCommand, { method: "POST", headers, body })
expect(legacy.status).toBe(200)
expect(await legacy.json()).toBe(true)
const effectCommand = nextCommandExecute()
const effect = await app().request(TuiPaths.executeCommand, { method: "POST", headers, body })
expect(effect.status).toBe(200)
expect(await effect.json()).toBe(true)
const legacyPublished = await legacyCommand
const effectPublished = await effectCommand
expect(effectPublished).toBe(legacyPublished)
expect(legacyPublished).toBeUndefined()
})
test("serves TUI control queue through experimental Effect routes", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context)
@@ -20,6 +20,7 @@ import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import { Project } from "../../src/project/project"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import {
WorkspaceRouteContext,
workspaceRouterMiddleware,
@@ -49,6 +50,7 @@ const it = testEffect(
NodeHttpServer.layerTest,
NodeServices.layer,
Project.defaultLayer,
Workspace.defaultLayer,
Socket.layerWebSocketConstructorGlobal,
),
)
@@ -115,16 +117,17 @@ const syncResponse = (request: HttpServerRequest.HttpServerRequest) => {
const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adaptor: WorkspaceAdaptor }) =>
Effect.acquireRelease(
Effect.promise(async () => {
Effect.gen(function* () {
registerAdaptor(input.projectID, input.type, input.adaptor)
return Workspace.create({
const workspace = yield* Workspace.Service
return yield* workspace.create({
type: input.type,
branch: null,
extra: null,
projectID: input.projectID,
})
}),
(workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore),
(info) => Workspace.Service.use((workspace) => workspace.remove(info.id)).pipe(Effect.ignore),
)
const createRemoteWorkspace = (input: {
@@ -387,6 +390,36 @@ describe("HttpApi workspace routing middleware", () => {
}),
)
it.live("keeps workspace control routes local even when workspace is selected", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceDir = path.join(dir, ".workspace-local")
const workspace = yield* createLocalWorkspace({
projectID: project.project.id,
type: "workspace-control-plane-target",
directory: workspaceDir,
})
// Workspace CRUD/status routes manage the control plane itself. Selecting
// a workspace should preserve the selected id for handlers, but must not
// swap the route context to the workspace target directory.
yield* HttpRouter.add(
"GET",
WorkspacePaths.list,
Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
}),
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
const response = yield* HttpClient.get(`${WorkspacePaths.list}?workspace=${workspace.id}`)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id })
}),
)
it.live("uses directory query/header fallback when no workspace is selected", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
@@ -1,7 +1,8 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import { afterEach, describe, expect, mock } from "bun:test"
import { NodeServices } from "@effect/platform-node"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
@@ -11,30 +12,28 @@ import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { WorkspaceRef } from "../../src/effect/instance-ref"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const it = testEffect(
Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer),
)
function request(path: string, directory: string, init: RequestInit = {}) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return Server.Default().app.request(path, { ...init, headers })
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>, workspaceID?: Workspace.Info["id"]) {
return Effect.runPromise(
fx.pipe(
workspaceID ? Effect.provideService(WorkspaceRef, workspaceID) : (effect) => effect,
Effect.provide(Session.defaultLayer),
),
)
return Effect.promise(() => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return Promise.resolve(Server.Default().app.request(path, { ...init, headers }))
})
}
function localAdaptor(directory: string): WorkspaceAdaptor {
@@ -134,245 +133,228 @@ afterEach(async () => {
})
describe("workspace HttpApi", () => {
test.todo("proxies remote workspace websocket through real Effect listener", () => {})
it.live("serves read endpoints", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
test("serves read endpoints", async () => {
await using tmp = await tmpdir({ git: true })
const [adaptors, workspaces, status] = yield* Effect.all([
request(WorkspacePaths.adaptors, dir),
request(WorkspacePaths.list, dir),
request(WorkspacePaths.status, dir),
])
const [adaptors, workspaces, status] = await Promise.all([
request(WorkspacePaths.adaptors, tmp.path),
request(WorkspacePaths.list, tmp.path),
request(WorkspacePaths.status, tmp.path),
])
expect(adaptors.status).toBe(200)
expect(await adaptors.json()).toEqual([
{
expect(adaptors.status).toBe(200)
expect(yield* Effect.promise(() => adaptors.json())).toContainEqual({
type: "worktree",
name: "Worktree",
description: "Create a git worktree",
},
])
expect(workspaces.status).toBe(200)
expect(await workspaces.json()).toEqual([])
expect(status.status).toBe(200)
expect(await status.json()).toEqual([])
})
test("serves mutation endpoints", async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () =>
registerAdaptor(Instance.project.id, "local-test", localAdaptor(path.join(tmp.path, ".workspace"))),
})
const created = await request(WorkspacePaths.list, tmp.path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-test", branch: null, extra: null }),
})
expect(created.status).toBe(200)
const workspace = (await created.json()) as Workspace.Info
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
const session = await Instance.provide({
directory: tmp.path,
fn: async () => runSession(Session.Service.use((svc) => svc.create({}))),
})
const restored = await request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), tmp.path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionID: session.id }),
})
expect(restored.status).toBe(200)
expect((await restored.json()) as { total: number }).toMatchObject({ total: expect.any(Number) })
const removed = await request(WorkspacePaths.remove.replace(":id", workspace.id), tmp.path, { method: "DELETE" })
expect(removed.status).toBe(200)
expect(await removed.json()).toMatchObject({ id: workspace.id })
const listed = await request(WorkspacePaths.list, tmp.path)
expect(listed.status).toBe(200)
expect(await listed.json()).toEqual([])
})
test("routes local workspace requests through the workspace target directory", async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ git: true })
const workspaceDir = path.join(tmp.path, ".workspace-local")
const workspace = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(Instance.project.id, "local-target", localAdaptor(workspaceDir))
return Workspace.create({
type: "local-target",
branch: null,
extra: null,
projectID: Instance.project.id,
})
},
})
const url = new URL(`http://localhost${InstancePaths.path}`)
url.searchParams.set("workspace", workspace.id)
try {
const response = await request(url.toString(), tmp.path)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ directory: workspaceDir })
} finally {
await Workspace.remove(workspace.id)
}
})
test("proxies remote workspace HTTP requests with sanitized forwarding", async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ git: true })
const proxied: ProxiedRequest[] = []
const remote = listenRemoteHttp((request) => {
proxied.push(request)
const url = new URL(request.url)
if (url.pathname === "/base/global/event") return eventStreamResponse()
if (url.pathname === "/base/sync/history") return Response.json([])
return new Response(
JSON.stringify({
proxied: true,
path: url.pathname,
keep: url.searchParams.get("keep"),
workspace: url.searchParams.get("workspace"),
}),
{
status: 201,
statusText: "Created",
headers: {
"content-length": "999",
"content-type": "application/json",
"x-remote": "yes",
},
},
)
})
const workspace = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(
Instance.project.id,
"remote-target",
remoteAdaptor(path.join(tmp.path, ".remote"), `http://127.0.0.1:${remote.port}/base`, {
"x-target-auth": "secret",
}),
)
return Workspace.create({
type: "remote-target",
branch: null,
extra: null,
projectID: Instance.project.id,
})
},
})
const url = new URL("http://localhost/config")
url.searchParams.set("workspace", workspace.id)
url.searchParams.set("keep", "yes")
try {
const response = await request(url.toString(), tmp.path, {
method: "PATCH",
headers: {
"accept-encoding": "br",
"content-type": "application/json",
"x-opencode-workspace": "internal",
},
body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
})
const responseBody = await response.text()
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 })
expect(response.headers.get("content-length")).toBeNull()
expect(response.headers.get("x-remote")).toBe("yes")
expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null })
const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config")
expect(forwarded).toEqual([
{
url: `http://127.0.0.1:${remote.port}/base/config?keep=yes`,
method: "PATCH",
headers: expect.objectContaining({
"content-type": "application/json",
"x-target-auth": "secret",
}),
body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
},
])
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory")
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace")
} finally {
remote.stop(true)
await Workspace.remove(workspace.id)
}
})
expect(workspaces.status).toBe(200)
expect(yield* Effect.promise(() => workspaces.json())).toEqual([])
test("proxies remote workspace requests selected from session ownership", async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ git: true })
const proxied: ProxiedRequest[] = []
const remote = listenRemoteHttp((request) => {
proxied.push(request)
const url = new URL(request.url)
if (url.pathname === "/base/global/event") return eventStreamResponse()
if (url.pathname === "/base/sync/history") return Response.json([])
return Response.json({ proxied: true, path: new URL(request.url).pathname })
})
expect(status.status).toBe(200)
expect(yield* Effect.promise(() => status.json())).toEqual([])
}),
)
const workspace = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(
Instance.project.id,
"remote-session-target",
remoteAdaptor(path.join(tmp.path, ".remote-session"), `http://127.0.0.1:${remote.port}/base`),
)
return Workspace.create({
type: "remote-session-target",
branch: null,
extra: null,
projectID: Instance.project.id,
})
},
})
const session = await Instance.provide({
directory: tmp.path,
fn: async () =>
runSession(
Session.Service.use((svc) => svc.create()),
workspace.id,
),
})
it.live("serves mutation endpoints", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
registerAdaptor(project.project.id, "local-test", localAdaptor(path.join(dir, ".workspace")))
try {
const response = await request(`http://localhost/session/${session.id}/message`, tmp.path, {
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }),
body: JSON.stringify({ type: "local-test", branch: null, extra: null }),
})
expect(created.status).toBe(200)
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir))
const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionID: session.id }),
})
expect(restored.status).toBe(200)
expect((yield* Effect.promise(() => restored.json())) as { total: number }).toMatchObject({
total: expect.any(Number),
})
const responseBody = await response.text()
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 })
expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` })
expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([
expect.objectContaining({
url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/message`,
method: "POST",
const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
expect(removed.status).toBe(200)
expect(yield* Effect.promise(() => removed.json())).toMatchObject({ id: workspace.id })
const listed = yield* request(WorkspacePaths.list, dir)
expect(listed.status).toBe(200)
expect(yield* Effect.promise(() => listed.json())).toEqual([])
}),
)
it.live("routes local workspace requests through the workspace target directory", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const workspaceDir = path.join(dir, ".workspace-local")
const project = yield* Project.use.fromDirectory(dir)
registerAdaptor(project.project.id, "local-target", localAdaptor(workspaceDir))
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-target", branch: null, extra: null }),
})
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
const url = new URL(`http://localhost${InstancePaths.path}`)
url.searchParams.set("workspace", workspace.id)
const response = yield* request(url.toString(), dir)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: workspaceDir })
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
}),
)
it.live("proxies remote workspace HTTP requests with sanitized forwarding", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const proxied: ProxiedRequest[] = []
const remote = listenRemoteHttp((request) => {
proxied.push(request)
const url = new URL(request.url)
if (url.pathname === "/base/global/event") return eventStreamResponse()
if (url.pathname === "/base/sync/history") return Response.json([])
return new Response(
JSON.stringify({
proxied: true,
path: url.pathname,
keep: url.searchParams.get("keep"),
workspace: url.searchParams.get("workspace"),
}),
{
status: 201,
statusText: "Created",
headers: {
"content-length": "999",
"content-type": "application/json",
"x-remote": "yes",
},
},
)
})
const project = yield* Project.use.fromDirectory(dir)
registerAdaptor(
project.project.id,
"remote-target",
remoteAdaptor(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, {
"x-target-auth": "secret",
}),
])
} finally {
remote.stop(true)
await Workspace.remove(workspace.id)
}
})
)
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "remote-target", branch: null, extra: null }),
})
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
const url = new URL("http://localhost/config")
url.searchParams.set("workspace", workspace.id)
url.searchParams.set("keep", "yes")
try {
const response = yield* request(url.toString(), dir, {
method: "PATCH",
headers: {
"accept-encoding": "br",
"content-type": "application/json",
"x-opencode-workspace": "internal",
},
body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
})
const responseBody = yield* Effect.promise(() => response.text())
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 })
expect(response.headers.get("content-length")).toBeNull()
expect(response.headers.get("x-remote")).toBe("yes")
expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null })
const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config")
expect(forwarded).toEqual([
{
url: `http://127.0.0.1:${remote.port}/base/config?keep=yes`,
method: "PATCH",
headers: expect.objectContaining({
"content-type": "application/json",
"x-target-auth": "secret",
}),
body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
},
])
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory")
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace")
} finally {
void remote.stop(true)
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
}
}),
)
it.live("proxies remote workspace requests selected from session ownership", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const proxied: ProxiedRequest[] = []
const remote = listenRemoteHttp((request) => {
proxied.push(request)
const url = new URL(request.url)
if (url.pathname === "/base/global/event") return eventStreamResponse()
if (url.pathname === "/base/sync/history") return Response.json([])
return Response.json({ proxied: true, path: new URL(request.url).pathname })
})
const project = yield* Project.use.fromDirectory(dir)
registerAdaptor(
project.project.id,
"remote-session-target",
remoteAdaptor(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`),
)
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }),
})
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
const session = yield* Session.Service.use((svc) => svc.create()).pipe(
Effect.provideService(WorkspaceRef, workspace.id),
provideInstance(dir),
)
try {
const response = yield* request(`http://localhost/session/${session.id}/message`, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }),
})
const responseBody = yield* Effect.promise(() => response.text())
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 })
expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` })
expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([
expect.objectContaining({
url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/message`,
method: "POST",
}),
])
} finally {
void remote.stop(true)
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
}
}),
)
})
+174 -315
View File
@@ -1,16 +1,76 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { Effect, FileSystem, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { NodeFileSystem } from "@effect/platform-node"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Config } from "@/config/config"
import { emptyConsoleState } from "@/config/console-state"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instruction } from "../../src/session/instruction"
import type { MessageV2 } from "../../src/session/message-v2"
import { Instance } from "../../src/project/instance"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { Global } from "@opencode-ai/core/global"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const run = <A>(effect: Effect.Effect<A, any, Instruction.Service>) =>
Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer)))
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
const configLayer = Layer.succeed(
Config.Service,
Config.Service.of({
get: () => Effect.succeed({}),
getGlobal: () => Effect.succeed({}),
getConsoleState: () => Effect.succeed(emptyConsoleState),
update: () => Effect.void,
updateGlobal: (config) => Effect.succeed(config),
invalidate: () => Effect.void,
directories: () => Effect.succeed([]),
waitForDependencies: () => Effect.void,
}),
)
const instructionLayer = (global: Partial<Global.Interface>) =>
Instruction.layer.pipe(
Layer.provide(configLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Global.layerWith(global)),
)
const provideInstruction =
(global: Partial<Global.Interface>) =>
<A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe(Effect.provide(instructionLayer(global)))
const write = (filepath: string, content: string) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(path.dirname(filepath), { recursive: true })
yield* fs.writeFileString(filepath, content)
})
const writeFiles = (dir: string, files: Record<string, string>) =>
Effect.all(
Object.entries(files).map(([file, content]) => write(path.join(dir, file), content)),
{ discard: true },
)
const withFiles = <A, E, R>(files: Record<string, string>, self: (dir: string) => Effect.Effect<A, E, R>) =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* writeFiles(dir, files)
return yield* self(dir).pipe(provideInstruction({ home: dir, config: dir }))
}),
)
const tmpWithFiles = (files: Record<string, string>) =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* writeFiles(dir, files)
return dir
})
function loaded(filepath: string): MessageV2.WithParts[] {
const sessionID = SessionID.make("session-loaded-1")
@@ -52,336 +112,135 @@ function loaded(filepath: string): MessageV2.WithParts[] {
}
describe("Instruction.resolve", () => {
test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions")
await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1")
},
})
await Instance.provide({
directory: tmp.path,
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const system = yield* svc.systemPaths()
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
it.live("returns empty when AGENTS.md is at project root (already in systemPaths)", () =>
withFiles({ "AGENTS.md": "# Root Instructions", "src/file.ts": "const x = 1" }, (dir) =>
Effect.gen(function* () {
const svc = yield* Instruction.Service
const system = yield* svc.systemPaths()
expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true)
const results = yield* svc.resolve(
[],
path.join(tmp.path, "src", "file.ts"),
MessageID.make("message-test-1"),
)
expect(results).toEqual([])
}),
),
),
})
})
const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("message-test-1"))
expect(results).toEqual([])
}),
),
)
test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
},
})
await Instance.provide({
directory: tmp.path,
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const system = yield* svc.systemPaths()
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
it.live("returns AGENTS.md from subdirectory (not in systemPaths)", () =>
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
Effect.gen(function* () {
const svc = yield* Instruction.Service
const system = yield* svc.systemPaths()
expect(system.has(path.join(dir, "subdir", "AGENTS.md"))).toBe(false)
const results = yield* svc.resolve(
[],
path.join(tmp.path, "subdir", "nested", "file.ts"),
MessageID.make("message-test-2"),
)
expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
}),
),
),
})
})
const results = yield* svc.resolve(
[],
path.join(dir, "subdir", "nested", "file.ts"),
MessageID.make("message-test-2"),
)
expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
}),
),
)
test("doesn't reload AGENTS.md when reading it directly", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
},
})
await Instance.provide({
directory: tmp.path,
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
const system = yield* svc.systemPaths()
expect(system.has(filepath)).toBe(false)
it.live("doesn't reload AGENTS.md when reading it directly", () =>
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
Effect.gen(function* () {
const svc = yield* Instruction.Service
const filepath = path.join(dir, "subdir", "AGENTS.md")
const system = yield* svc.systemPaths()
expect(system.has(filepath)).toBe(false)
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
expect(results).toEqual([])
}),
),
),
})
})
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
expect(results).toEqual([])
}),
),
)
test("does not reattach the same nearby instructions twice for one message", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
},
})
await Instance.provide({
directory: tmp.path,
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-1")
it.live("does not reattach the same nearby instructions twice for one message", () =>
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
Effect.gen(function* () {
const svc = yield* Instruction.Service
const filepath = path.join(dir, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-1")
const first = yield* svc.resolve([], filepath, id)
const second = yield* svc.resolve([], filepath, id)
const first = yield* svc.resolve([], filepath, id)
const second = yield* svc.resolve([], filepath, id)
expect(first).toHaveLength(1)
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
expect(second).toEqual([])
}),
),
),
})
})
expect(first).toHaveLength(1)
expect(first[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
expect(second).toEqual([])
}),
),
)
test("clear allows nearby instructions to be attached again for the same message", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
},
})
await Instance.provide({
directory: tmp.path,
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-2")
it.live("clear allows nearby instructions to be attached again for the same message", () =>
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
Effect.gen(function* () {
const svc = yield* Instruction.Service
const filepath = path.join(dir, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-2")
const first = yield* svc.resolve([], filepath, id)
yield* svc.clear(id)
const second = yield* svc.resolve([], filepath, id)
const first = yield* svc.resolve([], filepath, id)
yield* svc.clear(id)
const second = yield* svc.resolve([], filepath, id)
expect(first).toHaveLength(1)
expect(second).toHaveLength(1)
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
}),
),
),
})
})
expect(first).toHaveLength(1)
expect(second).toHaveLength(1)
expect(second[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
}),
),
)
test("skips instructions already reported by prior read metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
},
})
await Instance.provide({
directory: tmp.path,
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const agents = path.join(tmp.path, "subdir", "AGENTS.md")
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-3")
it.live("skips instructions already reported by prior read metadata", () =>
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
Effect.gen(function* () {
const svc = yield* Instruction.Service
const agents = path.join(dir, "subdir", "AGENTS.md")
const filepath = path.join(dir, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-3")
const results = yield* svc.resolve(loaded(agents), filepath, id)
expect(results).toEqual([])
}),
),
),
})
})
const results = yield* svc.resolve(loaded(agents), filepath, id)
expect(results).toEqual([])
}),
),
)
test.todo("fetches remote instructions from config URLs via HttpClient", () => {})
})
describe("Instruction.system", () => {
test("loads both project and global AGENTS.md when both exist", async () => {
const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
delete process.env["OPENCODE_CONFIG_DIR"]
it.live("loads both project and global AGENTS.md when both exist", () =>
Effect.gen(function* () {
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
const projectTmp = yield* tmpWithFiles({ "AGENTS.md": "# Project Instructions" })
await using globalTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
},
})
await using projectTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions")
},
})
yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(projectTmp, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
const originalGlobalConfig = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
try {
await Instance.provide({
directory: projectTmp.path,
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
const rules = yield* svc.system()
expect(rules).toHaveLength(2)
expect(rules[0]).toBe(
`Instructions from: ${path.join(globalTmp.path, "AGENTS.md")}\n# Global Instructions`,
)
expect(rules[1]).toBe(
`Instructions from: ${path.join(projectTmp.path, "AGENTS.md")}\n# Project Instructions`,
)
}),
),
),
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
if (originalConfigDir === undefined) {
delete process.env["OPENCODE_CONFIG_DIR"]
} else {
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
}
}
})
const rules = yield* svc.system()
expect(rules).toHaveLength(2)
expect(rules[0]).toBe(`Instructions from: ${path.join(globalTmp, "AGENTS.md")}\n# Global Instructions`)
expect(rules[1]).toBe(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n# Project Instructions`)
}).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp }))
}),
)
})
describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
let originalConfigDir: string | undefined
describe("Instruction.systemPaths global config", () => {
it.live("uses Global.Service config AGENTS.md", () =>
Effect.gen(function* () {
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
const projectTmp = yield* tmpdirScoped()
beforeEach(() => {
originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
})
afterEach(() => {
if (originalConfigDir === undefined) {
delete process.env["OPENCODE_CONFIG_DIR"]
} else {
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
}
})
test("prefers OPENCODE_CONFIG_DIR AGENTS.md over global when both exist", async () => {
await using profileTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Profile Instructions")
},
})
await using globalTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
},
})
await using projectTmp = await tmpdir()
process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path
const originalGlobalConfig = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
try {
await Instance.provide({
directory: projectTmp.path,
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
}),
),
),
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
}
})
test("falls back to global AGENTS.md when OPENCODE_CONFIG_DIR has no AGENTS.md", async () => {
await using profileTmp = await tmpdir()
await using globalTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
},
})
await using projectTmp = await tmpdir()
process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path
const originalGlobalConfig = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
try {
await Instance.provide({
directory: projectTmp.path,
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
}),
),
),
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
}
})
test("uses global AGENTS.md when OPENCODE_CONFIG_DIR is not set", async () => {
await using globalTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
},
})
await using projectTmp = await tmpdir()
delete process.env["OPENCODE_CONFIG_DIR"]
const originalGlobalConfig = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
try {
await Instance.provide({
directory: projectTmp.path,
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
}),
),
),
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
}
})
yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
}).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp }))
}),
)
})
+85 -78
View File
@@ -2,28 +2,31 @@ import { describe, expect, test } from "bun:test"
import type { NamedError } from "@opencode-ai/core/util/error"
import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises"
import { Effect, Schedule } from "effect"
import { Effect, Layer, Schedule } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
import { ProviderID } from "../../src/provider/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
import { SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const providerID = ProviderID.make("test")
const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
function apiError(headers?: Record<string, string>): MessageV2.APIError {
return new MessageV2.APIError({
message: "boom",
isRetryable: true,
responseHeaders: headers,
}).toObject() as MessageV2.APIError
return MessageV2.APIError.Schema.parse(
new MessageV2.APIError({
message: "boom",
isRetryable: true,
responseHeaders: headers,
}).toObject(),
)
}
function wrap(message: unknown): ReturnType<NamedError["toObject"]> {
return { data: { message } } as ReturnType<NamedError["toObject"]>
return { name: "", data: { message } }
}
describe("session.retry.delay", () => {
@@ -80,47 +83,36 @@ describe("session.retry.delay", () => {
expect(SessionRetry.delay(1, error)).toBe(SessionRetry.RETRY_MAX_DELAY)
})
test("policy updates retry status and increments attempts", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live("policy updates retry status and increments attempts", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessionID = SessionID.make("session-retry-test")
const error = apiError({ "retry-after-ms": "0" })
const status = yield* SessionStatus.Service
await Effect.runPromise(
Effect.gen(function* () {
const step = yield* Schedule.toStepWithMetadata(
SessionRetry.policy({
parse: (err) => err as MessageV2.APIError,
set: (info) =>
Effect.promise(() =>
AppRuntime.runPromise(
SessionStatus.Service.use((svc) =>
svc.set(sessionID, {
type: "retry",
attempt: info.attempt,
message: info.message,
next: info.next,
}),
),
),
),
const step = yield* Schedule.toStepWithMetadata(
SessionRetry.policy({
parse: (err) => MessageV2.APIError.Schema.parse(err),
set: (info) =>
status.set(sessionID, {
type: "retry",
attempt: info.attempt,
message: info.message,
next: info.next,
}),
)
yield* step(error)
yield* step(error)
}),
)
yield* step(error)
yield* step(error)
expect(await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(sessionID)))).toMatchObject({
expect(yield* status.get(sessionID)).toMatchObject({
type: "retry",
attempt: 2,
message: "boom",
})
},
})
})
}),
),
)
})
describe("session.retry.retryable", () => {
@@ -173,58 +165,68 @@ describe("session.retry.retryable", () => {
const error = new MessageV2.ContextOverflowError({
message: "Input exceeds context window of this model",
responseBody: '{"error":{"code":"context_length_exceeded"}}',
}).toObject() as ReturnType<NamedError["toObject"]>
}).toObject()
expect(SessionRetry.retryable(error)).toBeUndefined()
})
test("retries 500 errors even when isRetryable is false", () => {
const error = new MessageV2.APIError({
message: "Internal server error",
isRetryable: false,
statusCode: 500,
responseBody: '{"type":"api_error","message":"Internal server error"}',
}).toObject() as MessageV2.APIError
const error = MessageV2.APIError.Schema.parse(
new MessageV2.APIError({
message: "Internal server error",
isRetryable: false,
statusCode: 500,
responseBody: '{"type":"api_error","message":"Internal server error"}',
}).toObject(),
)
expect(SessionRetry.retryable(error)).toBe("Internal server error")
})
test("retries 502 bad gateway errors", () => {
const error = new MessageV2.APIError({
message: "Bad gateway",
isRetryable: false,
statusCode: 502,
}).toObject() as MessageV2.APIError
const error = MessageV2.APIError.Schema.parse(
new MessageV2.APIError({
message: "Bad gateway",
isRetryable: false,
statusCode: 502,
}).toObject(),
)
expect(SessionRetry.retryable(error)).toBe("Bad gateway")
})
test("retries 503 service unavailable errors", () => {
const error = new MessageV2.APIError({
message: "Service unavailable",
isRetryable: false,
statusCode: 503,
}).toObject() as MessageV2.APIError
const error = MessageV2.APIError.Schema.parse(
new MessageV2.APIError({
message: "Service unavailable",
isRetryable: false,
statusCode: 503,
}).toObject(),
)
expect(SessionRetry.retryable(error)).toBe("Service unavailable")
})
test("does not retry 4xx errors when isRetryable is false", () => {
const error = new MessageV2.APIError({
message: "Bad request",
isRetryable: false,
statusCode: 400,
}).toObject() as MessageV2.APIError
const error = MessageV2.APIError.Schema.parse(
new MessageV2.APIError({
message: "Bad request",
isRetryable: false,
statusCode: 400,
}).toObject(),
)
expect(SessionRetry.retryable(error)).toBeUndefined()
})
test("retries ZlibError decompression failures", () => {
const error = new MessageV2.APIError({
message: "Response decompression failed",
isRetryable: true,
metadata: { code: "ZlibError" },
}).toObject() as MessageV2.APIError
const error = MessageV2.APIError.Schema.parse(
new MessageV2.APIError({
message: "Response decompression failed",
isRetryable: true,
metadata: { code: "ZlibError" },
}).toObject(),
)
const retryable = SessionRetry.retryable(error)
expect(retryable).toBeDefined()
@@ -261,20 +263,23 @@ describe("session.message-v2.fromError", () => {
const result = MessageV2.fromError(error, { providerID })
expect(MessageV2.APIError.isInstance(result)).toBe(true)
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
expect((result as MessageV2.APIError).data.message).toBe("Connection reset by server")
expect((result as MessageV2.APIError).data.metadata?.code).toBe("ECONNRESET")
expect((result as MessageV2.APIError).data.metadata?.message).toInclude("socket connection")
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(true)
expect(result.data.message).toBe("Connection reset by server")
expect(result.data.metadata?.code).toBe("ECONNRESET")
expect(result.data.metadata?.message).toInclude("socket connection")
},
15_000,
)
test("ECONNRESET socket error is retryable", () => {
const error = new MessageV2.APIError({
message: "Connection reset by server",
isRetryable: true,
metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" },
}).toObject() as MessageV2.APIError
const error = MessageV2.APIError.Schema.parse(
new MessageV2.APIError({
message: "Connection reset by server",
isRetryable: true,
metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" },
}).toObject(),
)
const retryable = SessionRetry.retryable(error)
expect(retryable).toBeDefined()
@@ -291,7 +296,8 @@ describe("session.message-v2.fromError", () => {
responseBody: '{"error":"boom"}',
isRetryable: false,
})
const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) as MessageV2.APIError
const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") })
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(true)
})
@@ -313,7 +319,8 @@ describe("session.message-v2.fromError", () => {
)
expect(MessageV2.APIError.isInstance(result)).toBe(true)
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(true)
expect(SessionRetry.retryable(result)).toBe("An error occurred while processing your request.")
})
})
+64 -60
View File
@@ -1,69 +1,73 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import type { Agent } from "../../src/agent/agent"
import { NamedError } from "@opencode-ai/core/util/error"
import { Skill } from "../../src/skill"
import { Permission } from "../../src/permission"
import { SystemPrompt } from "../../src/session/system"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
const skills: Skill.Info[] = [
{
name: "zeta-skill",
description: "Zeta skill.",
location: "/tmp/zeta-skill/SKILL.md",
content: "# zeta-skill",
},
{
name: "alpha-skill",
description: "Alpha skill.",
location: "/tmp/alpha-skill/SKILL.md",
content: "# alpha-skill",
},
{
name: "middle-skill",
description: "Middle skill.",
location: "/tmp/middle-skill/SKILL.md",
content: "# middle-skill",
},
]
const build: Agent.Info = {
name: "build",
mode: "primary",
permission: Permission.fromConfig({ "*": "allow" }),
options: {},
}
const it = testEffect(
SystemPrompt.layer.pipe(
Layer.provide(
Layer.succeed(
Skill.Service,
Skill.Service.of({
get: (name) => Effect.succeed(skills.find((skill) => skill.name === name)),
all: () => Effect.succeed(skills),
dirs: () => Effect.succeed([]),
available: () => Effect.succeed(skills),
}),
),
),
),
)
describe("session.system", () => {
test("skills output is sorted by name and stable across calls", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
for (const [name, description] of [
["zeta-skill", "Zeta skill."],
["alpha-skill", "Alpha skill."],
["middle-skill", "Middle skill."],
]) {
const skillDir = path.join(dir, ".opencode", "skill", name)
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: ${name}
description: ${description}
---
it.effect("skills output is sorted by name and stable across calls", () =>
Effect.gen(function* () {
const prompt = yield* SystemPrompt.Service
const first = yield* prompt.skills(build)
const second = yield* prompt.skills(build)
const output = first ?? (yield* Effect.fail(new NamedError.Unknown({ message: "missing skills output" })))
# ${name}
`,
)
}
},
})
expect(first).toBe(second)
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
const alpha = output.indexOf("<name>alpha-skill</name>")
const middle = output.indexOf("<name>middle-skill</name>")
const zeta = output.indexOf("<name>zeta-skill</name>")
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await load(tmp.path, (svc) => svc.get("build"))
const runSkills = Effect.gen(function* () {
const svc = yield* SystemPrompt.Service
return yield* svc.skills(build!)
}).pipe(Effect.provide(SystemPrompt.defaultLayer))
const first = await Effect.runPromise(runSkills)
const second = await Effect.runPromise(runSkills)
expect(first).toBe(second)
const alpha = first!.indexOf("<name>alpha-skill</name>")
const middle = first!.indexOf("<name>middle-skill</name>")
const zeta = first!.indexOf("<name>zeta-skill</name>")
expect(alpha).toBeGreaterThan(-1)
expect(middle).toBeGreaterThan(alpha)
expect(zeta).toBeGreaterThan(middle)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = home
}
})
expect(alpha).toBeGreaterThan(-1)
expect(middle).toBeGreaterThan(alpha)
expect(zeta).toBeGreaterThan(middle)
}),
)
})
+174 -155
View File
@@ -1,16 +1,18 @@
import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { Schema } from "effect"
import { describe, expect, beforeEach, afterEach, afterAll } from "bun:test"
import { provideTmpdirInstance } from "../fixture/fixture"
import { Effect, Layer, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { SyncEvent } from "../../src/sync"
import { Database } from "@/storage/db"
import { EventTable } from "../../src/sync/event.sql"
import { Identifier } from "../../src/id/id"
import { MessageID } from "../../src/session/schema"
import { Flag } from "@opencode-ai/core/flag/flag"
import { initProjectors } from "../../src/server/projectors"
import { testEffect } from "../lib/effect"
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const it = testEffect(Layer.mergeAll(SyncEvent.defaultLayer, CrossSpawnSpawner.defaultLayer))
beforeEach(() => {
Database.close()
@@ -22,19 +24,6 @@ afterEach(() => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
})
function withInstance(fn: () => void | Promise<void>) {
return async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await fn()
},
})
}
}
describe("SyncEvent", () => {
function setup() {
SyncEvent.reset()
@@ -59,179 +48,209 @@ describe("SyncEvent", () => {
return { Created, Sent }
}
function expectDefect<A, E, R>(effect: Effect.Effect<A, E, R>, pattern: RegExp) {
return Effect.gen(function* () {
const exit = yield* Effect.exit(effect)
if (exit._tag === "Success") throw new Error("Expected effect to fail")
expect(String(exit.cause)).toMatch(pattern)
})
}
afterAll(() => {
SyncEvent.reset()
initProjectors()
})
describe("run", () => {
test(
it.live(
"inserts event row",
withInstance(() => {
const { Created } = setup()
SyncEvent.run(Created, { id: "evt_1", name: "first" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].type).toBe("item.created.1")
expect(rows[0].aggregate_id).toBe("evt_1")
}),
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].type).toBe("item.created.1")
expect(rows[0].aggregate_id).toBe("evt_1")
}),
),
)
test(
it.live(
"increments seq per aggregate",
withInstance(() => {
const { Created } = setup()
SyncEvent.run(Created, { id: "evt_1", name: "first" })
SyncEvent.run(Created, { id: "evt_1", name: "second" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(2)
expect(rows[1].seq).toBe(rows[0].seq + 1)
}),
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" })
yield* SyncEvent.use.run(Created, { id: "evt_1", name: "second" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(2)
expect(rows[1].seq).toBe(rows[0].seq + 1)
}),
),
)
test(
it.live(
"uses custom aggregate field from agg()",
withInstance(() => {
const { Sent } = setup()
SyncEvent.run(Sent, { item_id: "evt_1", to: "james" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].aggregate_id).toBe("evt_1")
}),
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Sent } = setup()
yield* SyncEvent.use.run(Sent, { item_id: "evt_1", to: "james" })
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].aggregate_id).toBe("evt_1")
}),
),
)
test(
it.live(
"emits events",
withInstance(async () => {
const { Created } = setup()
const events: Array<{
type: string
properties: { id: string; name: string }
}> = []
const received = new Promise<void>((resolve) => {
Bus.subscribeAll((event) => {
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const events: Array<{
type: string
properties: { id: string; name: string }
}> = []
let resolve = () => {}
const received = new Promise<void>((done) => {
resolve = done
})
const dispose = Bus.subscribeAll((event) => {
events.push(event)
resolve()
})
})
SyncEvent.run(Created, { id: "evt_1", name: "test" })
await received
expect(events).toHaveLength(1)
expect(events[0]).toEqual({
type: "item.created",
properties: {
id: "evt_1",
name: "test",
},
})
}),
try {
yield* SyncEvent.use.run(Created, { id: "evt_1", name: "test" })
yield* Effect.promise(() => received)
expect(events).toHaveLength(1)
expect(events[0]).toEqual({
type: "item.created",
properties: {
id: "evt_1",
name: "test",
},
})
} finally {
dispose()
}
}),
),
)
})
describe("replay", () => {
test(
it.live(
"inserts event from external payload",
withInstance(() => {
const id = Identifier.descending("message")
SyncEvent.replay({
id: "evt_1",
type: "item.created.1",
seq: 0,
aggregateID: id,
data: { id, name: "replayed" },
})
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].aggregate_id).toBe(id)
}),
)
test(
"throws on sequence mismatch",
withInstance(() => {
const id = Identifier.descending("message")
SyncEvent.replay({
id: "evt_1",
type: "item.created.1",
seq: 0,
aggregateID: id,
data: { id, name: "first" },
})
expect(() =>
SyncEvent.replay({
provideTmpdirInstance(() =>
Effect.gen(function* () {
const id = MessageID.ascending()
yield* SyncEvent.use.replay({
id: "evt_1",
type: "item.created.1",
seq: 5,
aggregateID: id,
data: { id, name: "bad" },
}),
).toThrow(/Sequence mismatch/)
}),
)
test(
"throws on unknown event type",
withInstance(() => {
expect(() =>
SyncEvent.replay({
id: "evt_1",
type: "unknown.event.1",
seq: 0,
aggregateID: "x",
data: {},
}),
).toThrow(/Unknown event type/)
}),
aggregateID: id,
data: { id, name: "replayed" },
})
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows).toHaveLength(1)
expect(rows[0].aggregate_id).toBe(id)
}),
),
)
test(
"replayAll accepts later chunks after the first batch",
withInstance(() => {
const { Created } = setup()
const id = Identifier.descending("message")
const one = SyncEvent.replayAll([
{
it.live(
"throws on sequence mismatch",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const id = MessageID.ascending()
yield* SyncEvent.use.replay({
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
type: "item.created.1",
seq: 0,
aggregateID: id,
data: { id, name: "first" },
},
{
id: "evt_2",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 1,
aggregateID: id,
data: { id, name: "second" },
},
])
})
yield* expectDefect(
SyncEvent.use.replay({
id: "evt_1",
type: "item.created.1",
seq: 5,
aggregateID: id,
data: { id, name: "bad" },
}),
/Sequence mismatch/,
)
}),
),
)
const two = SyncEvent.replayAll([
{
id: "evt_3",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 2,
aggregateID: id,
data: { id, name: "third" },
},
{
id: "evt_4",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 3,
aggregateID: id,
data: { id, name: "fourth" },
},
])
it.live(
"throws on unknown event type",
provideTmpdirInstance(() =>
Effect.gen(function* () {
yield* expectDefect(
SyncEvent.use.replay({
id: "evt_1",
type: "unknown.event.1",
seq: 0,
aggregateID: "x",
data: {},
}),
/Unknown event type/,
)
}),
),
)
expect(one).toBe(id)
expect(two).toBe(id)
it.live(
"replayAll accepts later chunks after the first batch",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const id = MessageID.ascending()
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
}),
const one = yield* SyncEvent.use.replayAll([
{
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 0,
aggregateID: id,
data: { id, name: "first" },
},
{
id: "evt_2",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 1,
aggregateID: id,
data: { id, name: "second" },
},
])
const two = yield* SyncEvent.use.replayAll([
{
id: "evt_3",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 2,
aggregateID: id,
data: { id, name: "third" },
},
{
id: "evt_4",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 3,
aggregateID: id,
data: { id, name: "fourth" },
},
])
expect(one).toBe(id)
expect(two).toBe(id)
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
}),
),
)
})
})
@@ -1,283 +0,0 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { GlobalBus } from "../../src/bus/global"
import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { Session as SessionNs } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { Database } from "@/storage/db"
import { asc } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { SyncEvent } from "../../src/sync"
import { EventTable } from "../../src/sync/event.sql"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
beforeEach(() => {
Database.close()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
})
afterEach(async () => {
mock.restore()
await Instance.disposeAll()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
await resetDatabase()
})
function create(input?: SessionNs.CreateInput) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
}
function get(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id)))
}
function updateMessage<T extends MessageV2.Info>(msg: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
}
function updatePart<T extends MessageV2.Part>(part: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
}
async function user(sessionID: SessionID, text: string) {
const msg = await updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
await updatePart({
id: PartID.ascending(),
sessionID,
messageID: msg.id,
type: "text",
text,
})
}
function remote(dir: string, url: string): WorkspaceAdaptor {
return {
name: "remote",
description: "remote",
configure(info) {
return {
...info,
directory: dir,
}
},
async create() {
await fs.mkdir(dir, { recursive: true })
},
async remove() {},
target() {
return {
type: "remote" as const,
url,
}
},
}
}
function local(dir: string): WorkspaceAdaptor {
return {
name: "local",
description: "local",
configure(info) {
return {
...info,
directory: dir,
}
},
async create() {
await fs.mkdir(dir, { recursive: true })
},
async remove() {},
target() {
return {
type: "local" as const,
directory: dir,
}
},
}
}
function eventStreamResponse() {
return new Response(new ReadableStream({ start() {} }), {
status: 200,
headers: {
"content-type": "text/event-stream",
},
})
}
describe("Workspace.sessionRestore", () => {
test("replays session events in batches of 10 and emits progress", async () => {
await using tmp = await tmpdir({ git: true })
const dir = path.join(tmp.path, ".restore")
const seen: any[] = []
const posts: Array<{
path: string
body: { directory: string; events: Array<{ seq: number; aggregateID: string }> }
}> = []
const on = (evt: any) => seen.push(evt)
GlobalBus.on("event", on)
const raw = globalThis.fetch
spyOn(globalThis, "fetch").mockImplementation(
Object.assign(
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url)
if (url.pathname === "/base/global/event") {
return eventStreamResponse()
}
if (url.pathname === "/base/sync/history") {
return Response.json([])
}
const body = JSON.parse(String(init?.body))
posts.push({
path: url.pathname,
body,
})
return Response.json({ sessionID: body.events[0].aggregateID })
},
{
preconnect: raw.preconnect?.bind(raw),
},
) as typeof globalThis.fetch,
)
try {
const setup = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(Instance.project.id, "worktree", remote(dir, "https://workspace.test/base"))
const space = await Workspace.create({
type: "worktree",
branch: null,
extra: null,
projectID: Instance.project.id,
})
const session = await create({})
for (let i = 0; i < 6; i++) {
await user(session.id, `msg ${i}`)
}
const rows = Database.use((db) =>
db
.select({ seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.aggregate_id, session.id))
.orderBy(asc(EventTable.seq))
.all(),
)
const result = await Workspace.sessionRestore({
workspaceID: space.id,
sessionID: session.id,
})
return { space, session, rows, result }
},
})
expect(setup.rows).toHaveLength(13)
expect(setup.result).toEqual({ total: 2 })
expect(posts).toHaveLength(2)
expect(posts[0]?.path).toBe("/base/sync/replay")
expect(posts[1]?.path).toBe("/base/sync/replay")
expect(posts[0]?.body.directory).toBe(dir)
expect(posts[1]?.body.directory).toBe(dir)
expect(posts[0]?.body.events).toHaveLength(10)
expect(posts[1]?.body.events).toHaveLength(4)
expect(posts.flatMap((item) => item.body.events.map((event) => event.seq))).toEqual([
...setup.rows.map((row) => row.seq),
setup.rows.at(-1)!.seq + 1,
])
expect(posts[1]?.body.events.at(-1)).toMatchObject({
aggregateID: setup.session.id,
seq: setup.rows.at(-1)!.seq + 1,
type: SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version),
data: {
sessionID: setup.session.id,
info: {
workspaceID: setup.space.id,
},
},
})
const restore = seen.filter(
(evt) => evt.workspace === setup.space.id && evt.payload.type === Workspace.Event.Restore.type,
)
expect(restore.map((evt) => evt.payload.properties.step)).toEqual([0, 1, 2])
expect(restore.map((evt) => evt.payload.properties.total)).toEqual([2, 2, 2])
expect(restore.map((evt) => evt.payload.properties.sessionID)).toEqual([
setup.session.id,
setup.session.id,
setup.session.id,
])
} finally {
GlobalBus.off("event", on)
}
})
test("replays locally without posting to a server", async () => {
await using tmp = await tmpdir({ git: true })
const dir = path.join(tmp.path, ".restore-local")
const seen: any[] = []
const on = (evt: any) => seen.push(evt)
GlobalBus.on("event", on)
const fetch = spyOn(globalThis, "fetch")
const replayAll = spyOn(SyncEvent, "replayAll")
try {
const setup = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(Instance.project.id, "local-restore", local(dir))
const space = await Workspace.create({
type: "local-restore",
branch: null,
extra: null,
projectID: Instance.project.id,
})
const session = await create({})
for (let i = 0; i < 6; i++) {
await user(session.id, `msg ${i}`)
}
const result = await Workspace.sessionRestore({
workspaceID: space.id,
sessionID: session.id,
})
const updated = await get(session.id)
return { space, session, result, updated }
},
})
expect(setup.result).toEqual({ total: 2 })
expect(fetch).not.toHaveBeenCalled()
expect(replayAll).toHaveBeenCalledTimes(2)
expect(setup.updated.workspaceID).toBe(setup.space.id)
const restore = seen.filter(
(evt) => evt.workspace === setup.space.id && evt.payload.type === Workspace.Event.Restore.type,
)
expect(restore.map((evt) => evt.payload.properties.step)).toEqual([0, 1, 2])
} finally {
GlobalBus.off("event", on)
}
})
})
+2 -2
View File
@@ -22,8 +22,8 @@
"zod": "catalog:"
},
"peerDependencies": {
"@opentui/core": ">=0.1.105",
"@opentui/solid": ">=0.1.105"
"@opentui/core": ">=0.2.0",
"@opentui/solid": ">=0.2.0"
},
"peerDependenciesMeta": {
"@opentui/core": {
+40 -40
View File
@@ -452,6 +452,38 @@ export type EventVcsBranchUpdated = {
}
}
export type EventWorkspaceReady = {
type: "workspace.ready"
properties: {
name: string
}
}
export type EventWorkspaceFailed = {
type: "workspace.failed"
properties: {
message: string
}
}
export type EventWorkspaceRestore = {
type: "workspace.restore"
properties: {
workspaceID: string
sessionID: string
total: number
step: number
}
}
export type EventWorkspaceStatus = {
type: "workspace.status"
properties: {
workspaceID: string
status: "connected" | "connecting" | "disconnected" | "error"
}
}
export type EventWorktreeReady = {
type: "worktree.ready"
properties: {
@@ -506,38 +538,6 @@ export type EventPtyDeleted = {
}
}
export type EventWorkspaceReady = {
type: "workspace.ready"
properties: {
name: string
}
}
export type EventWorkspaceFailed = {
type: "workspace.failed"
properties: {
message: string
}
}
export type EventWorkspaceRestore = {
type: "workspace.restore"
properties: {
workspaceID: string
sessionID: string
total: number
step: number
}
}
export type EventWorkspaceStatus = {
type: "workspace.status"
properties: {
workspaceID: string
status: "connected" | "connecting" | "disconnected" | "error"
}
}
export type OutputFormatText = {
type: "text"
}
@@ -1141,16 +1141,16 @@ export type GlobalEvent = {
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventVcsBranchUpdated
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceRestore
| EventWorkspaceStatus
| EventWorktreeReady
| EventWorktreeFailed
| EventPtyCreated
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceRestore
| EventWorkspaceStatus
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
@@ -2084,16 +2084,16 @@ export type Event =
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventVcsBranchUpdated
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceRestore
| EventWorkspaceStatus
| EventWorktreeReady
| EventWorktreeFailed
| EventPtyCreated
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceRestore
| EventWorkspaceStatus
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
+123 -129
View File
@@ -8743,6 +8743,102 @@
},
"required": ["type", "properties"]
},
"Event.workspace.ready": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.ready"
},
"properties": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"]
}
},
"required": ["type", "properties"]
},
"Event.workspace.failed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.failed"
},
"properties": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": ["message"]
}
},
"required": ["type", "properties"]
},
"Event.workspace.restore": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.restore"
},
"properties": {
"type": "object",
"properties": {
"workspaceID": {
"type": "string",
"pattern": "^wrk.*"
},
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"total": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"step": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"required": ["workspaceID", "sessionID", "total", "step"]
}
},
"required": ["type", "properties"]
},
"Event.workspace.status": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.status"
},
"properties": {
"type": "object",
"properties": {
"workspaceID": {
"type": "string",
"pattern": "^wrk.*"
},
"status": {
"type": "string",
"enum": ["connected", "connecting", "disconnected", "error"]
}
},
"required": ["workspaceID", "status"]
}
},
"required": ["type", "properties"]
},
"Event.worktree.ready": {
"type": "object",
"properties": {
@@ -8901,102 +8997,6 @@
},
"required": ["type", "properties"]
},
"Event.workspace.ready": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.ready"
},
"properties": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"]
}
},
"required": ["type", "properties"]
},
"Event.workspace.failed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.failed"
},
"properties": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": ["message"]
}
},
"required": ["type", "properties"]
},
"Event.workspace.restore": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.restore"
},
"properties": {
"type": "object",
"properties": {
"workspaceID": {
"type": "string",
"pattern": "^wrk.*"
},
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"total": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"step": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"required": ["workspaceID", "sessionID", "total", "step"]
}
},
"required": ["type", "properties"]
},
"Event.workspace.status": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.status"
},
"properties": {
"type": "object",
"properties": {
"workspaceID": {
"type": "string",
"pattern": "^wrk.*"
},
"status": {
"type": "string",
"enum": ["connected", "connecting", "disconnected", "error"]
}
},
"required": ["workspaceID", "status"]
}
},
"required": ["type", "properties"]
},
"OutputFormatText": {
"type": "object",
"properties": {
@@ -10341,9 +10341,7 @@
"maximum": 9007199254740991
},
"archived": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
"type": "number"
}
},
"required": ["created", "updated"]
@@ -10854,9 +10852,7 @@
"archived": {
"anyOf": [
{
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
"type": "number"
},
{
"type": "null"
@@ -11051,6 +11047,18 @@
{
"$ref": "#/components/schemas/Event.vcs.branch.updated"
},
{
"$ref": "#/components/schemas/Event.workspace.ready"
},
{
"$ref": "#/components/schemas/Event.workspace.failed"
},
{
"$ref": "#/components/schemas/Event.workspace.restore"
},
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{
"$ref": "#/components/schemas/Event.worktree.ready"
},
@@ -11069,18 +11077,6 @@
{
"$ref": "#/components/schemas/Event.pty.deleted"
},
{
"$ref": "#/components/schemas/Event.workspace.ready"
},
{
"$ref": "#/components/schemas/Event.workspace.failed"
},
{
"$ref": "#/components/schemas/Event.workspace.restore"
},
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{
"$ref": "#/components/schemas/Event.message.updated"
},
@@ -12773,9 +12769,7 @@
"maximum": 9007199254740991
},
"archived": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
"type": "number"
}
},
"required": ["created", "updated"]
@@ -13346,6 +13340,18 @@
{
"$ref": "#/components/schemas/Event.vcs.branch.updated"
},
{
"$ref": "#/components/schemas/Event.workspace.ready"
},
{
"$ref": "#/components/schemas/Event.workspace.failed"
},
{
"$ref": "#/components/schemas/Event.workspace.restore"
},
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{
"$ref": "#/components/schemas/Event.worktree.ready"
},
@@ -13364,18 +13370,6 @@
{
"$ref": "#/components/schemas/Event.pty.deleted"
},
{
"$ref": "#/components/schemas/Event.workspace.ready"
},
{
"$ref": "#/components/schemas/Event.workspace.failed"
},
{
"$ref": "#/components/schemas/Event.workspace.restore"
},
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{
"$ref": "#/components/schemas/Event.message.updated"
},