Compare commits

..

13 Commits

Author SHA1 Message Date
Kit Langton
68ff83a98d refactor(opencode): simplify database effect reset lifecycle 2026-05-02 23:23:32 -04:00
Kit Langton
fd4887d45d refactor(effect-drizzle-sqlite): simplify single-row reads 2026-05-02 23:23:32 -04:00
Kit Langton
25e546c837 refactor(effect-drizzle-sqlite): use Effectable prototype 2026-05-02 23:23:32 -04:00
Kit Langton
f948a1e3b0 test(opencode): pin lifecycle invariants for DatabaseEffect + managed-runtime
Adds regression tests for the two non-obvious invariants enforced by the
Effect-Drizzle integration:

- packages/opencode/test/storage/db-effect.test.ts pins that
  DatabaseEffect.layer rebuilds a fresh handle after Database.close + dispose,
  and demonstrates the shared-memoMap poisoning that resetDatabase prevents
  by disposing every DB-consuming runtime before closing the SQLite handle.

- packages/opencode/test/effect/managed-runtime.test.ts pins makeManagedRuntime
  dispose semantics and the lazy.resetIf compare-and-reset guard so a
  rebuilt instance is never clobbered by a stale dispose.
2026-05-02 23:23:32 -04:00
Kit Langton
46996e5a67 refactor(opencode): extract managed-runtime helper, prune adapter dead code
- Extract makeManagedRuntime() to src/effect/managed-runtime.ts so AppRuntime
  and BootstrapRuntime stop duplicating the lazy ManagedRuntime + dispose
  pattern, and document the shared-memoMap dispose ordering invariant.
- Add lazy.resetIf(expected) and use it in 3 compare-and-reset call sites
  (db.close, AppRuntime.dispose, disposeWebHandler).
- Drop dead `filename` option from EffectDrizzleSqlite MakeConfig.
- Drop redundant `patched` IIFE flag (patchClass is already idempotent).
- Add module-load assertion that Effect's protocol keys are present so a
  silent breakage on an Effect upgrade becomes a loud failure at import.
- Collapse share-next test `live()` into the wider `wired()` factory.
- Document lifecycle constraint in db-effect.ts and test/fixture/db.ts.
2026-05-02 23:23:32 -04:00
Kit Langton
322bb01257 refactor(opencode): unify drizzle client through effect adapter
The Effect adapter is now the only Drizzle wrapper over the bun:sqlite
handle. Database.Client owns the lifecycle, DatabaseEffect.Service just
exposes that handle. Removes acquire/release ref counting that was
ceremony around the same module-level singleton.
2026-05-02 23:23:32 -04:00
Kit Langton
aaa42cca07 fix(opencode): own effect sqlite lifecycle in layers 2026-05-02 23:23:32 -04:00
Kit Langton
8e2c15214e fix(opencode): refresh effect sqlite client after reset 2026-05-02 23:23:32 -04:00
Kit Langton
4faa6c64d6 feat(opencode): pilot effect sqlite database service 2026-05-02 23:23:32 -04:00
Kit Langton
f6f6cd0515 fix(effect-drizzle-sqlite): simplify sqlite adapter 2026-05-02 23:23:32 -04:00
Kit Langton
efcbc153ee test(effect-drizzle-sqlite): cover transaction edge cases 2026-05-02 23:23:32 -04:00
Kit Langton
e4ae265d8f fix(effect-drizzle-sqlite): support pipeable transactions 2026-05-02 23:23:32 -04:00
Kit Langton
89efce865d feat(effect-drizzle-sqlite): add sqlite adapter 2026-05-02 23:23:31 -04:00
67 changed files with 12034 additions and 13144 deletions

View File

@@ -1,5 +1,6 @@
name: Bug report
description: Report an issue that should be fixed
labels: ["bug"]
body:
- type: textarea
id: description

View File

@@ -1,5 +1,6 @@
name: 🚀 Feature Request
description: Suggest an idea, feature, or enhancement
labels: [discussion]
title: "[FEATURE]:"
body:

View File

@@ -1,5 +1,6 @@
name: Question
description: Ask a question
labels: ["question"]
body:
- type: textarea
id: question

View File

@@ -11,5 +11,6 @@ MrMushrooooom
nexxeln
R44VC0RP
rekram1-node
RhysSullivan
thdxr
simonklee

41
.github/VOUCHED.td vendored Normal file
View File

@@ -0,0 +1,41 @@
# Vouched contributors for this project.
#
# See https://github.com/mitchellh/vouch for details.
#
# Syntax:
# - One handle per line (without @), sorted alphabetically.
# - Optional platform prefix: platform:username (e.g., github:user).
# - Denounce with minus prefix: -username or -platform:username.
# - Optional details after a space following the handle.
adamdotdevin
-agusbasari29 AI PR slop
ariane-emory
-atharvau AI review spamming literally every PR
-borealbytes
-carycooper777
-danieljoshuanazareth
-danieljoshuanazareth
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person
dmtrkovalenko
edemaine
fahreddinozcan
-florianleibert
fwang
iamdavidhill
jayair
kitlangton
kommander
-opencode2026
-opencodeengineer bot that spams issues
r44vc0rp
rekram1-node
-ricardo-m-l
-robinmordasiewicz
rubdos
-saisharan0103 spamming ai prs
shantur
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
-terisuke
thdxr
-toastythebot

170
.github/workflows/daily-issues-recap.yml vendored Normal file
View File

@@ -0,0 +1,170 @@
name: daily-issues-recap
on:
schedule:
# Run at 6 PM EST (23:00 UTC, or 22:00 UTC during daylight saving)
- cron: "0 23 * * *"
workflow_dispatch: # Allow manual trigger for testing
jobs:
daily-recap:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Generate daily issues recap
id: recap
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh issue*": "allow",
"gh search*": "allow"
},
"webfetch": "deny",
"edit": "deny",
"write": "deny"
}
run: |
# Get today's date range
TODAY=$(date -u +%Y-%m-%d)
opencode run -m opencode/claude-sonnet-4-5 "Generate a daily issues recap for the OpenCode repository.
TODAY'S DATE: ${TODAY}
STEP 1: Gather today's issues
Search for all OPEN issues created today (${TODAY}) using:
gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500
IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these:
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
This recap is specifically for COMMUNITY (external) issues only.
STEP 2: Analyze and categorize
For each issue created today, categorize it:
**Severity Assessment:**
- CRITICAL: Crashes, data loss, security issues, blocks major functionality
- HIGH: Significant bugs affecting many users, important features broken
- MEDIUM: Bugs with workarounds, minor features broken
- LOW: Minor issues, cosmetic, nice-to-haves
**Activity Assessment:**
- Note issues with high comment counts or engagement
- Note issues from repeat reporters (check if author has filed before)
STEP 3: Cross-reference with existing issues
For issues that seem like feature requests or recurring bugs:
- Search for similar older issues to identify patterns
- Note if this is a frequently requested feature
- Identify any issues that are duplicates of long-standing requests
STEP 4: Generate the recap
Create a structured recap with these sections:
===DISCORD_START===
**Daily Issues Recap - ${TODAY}**
**Summary Stats**
- Total issues opened today: [count]
- By category: [bugs/features/questions]
**Critical/High Priority Issues**
[List any CRITICAL or HIGH severity issues with brief descriptions and issue numbers]
**Most Active/Discussed**
[Issues with significant engagement or from active community members]
**Trending Topics**
[Patterns noticed - e.g., 'Multiple reports about X', 'Continued interest in Y feature']
**Duplicates & Related**
[Issues that relate to existing open issues]
===DISCORD_END===
STEP 5: Format for Discord
Format the recap as a Discord-compatible message:
- Use Discord markdown (**, __, etc.)
- BE EXTREMELY CONCISE - this is an EOD summary, not a detailed report
- Use hyperlinked issue numbers with suppressed embeds: [#1234](<https://github.com/${{ github.repository }}/issues/1234>)
- Group related issues on single lines where possible
- Add emoji sparingly for critical items only
- HARD LIMIT: Keep under 1800 characters total
- Skip sections that have nothing notable (e.g., if no critical issues, omit that section)
- Prioritize signal over completeness - only surface what matters
OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/recap_raw.txt
# Extract only the Discord message between markers
sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/recap_raw.txt | grep -v '===DISCORD' > /tmp/recap.txt
echo "recap_file=/tmp/recap.txt" >> $GITHUB_OUTPUT
- name: Post to Discord
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }}
run: |
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post"
cat /tmp/recap.txt
exit 0
fi
# Read the recap
RECAP_RAW=$(cat /tmp/recap.txt)
RECAP_LENGTH=${#RECAP_RAW}
echo "Recap length: ${RECAP_LENGTH} chars"
# Function to post a message to Discord
post_to_discord() {
local msg="$1"
local content=$(echo "$msg" | jq -Rs '.')
curl -s -H "Content-Type: application/json" \
-X POST \
-d "{\"content\": ${content}}" \
"$DISCORD_WEBHOOK_URL"
sleep 1
}
# If under limit, send as single message
if [ "$RECAP_LENGTH" -le 1950 ]; then
post_to_discord "$RECAP_RAW"
else
echo "Splitting into multiple messages..."
remaining="$RECAP_RAW"
while [ ${#remaining} -gt 0 ]; do
if [ ${#remaining} -le 1950 ]; then
post_to_discord "$remaining"
break
else
chunk="${remaining:0:1900}"
last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1)
if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then
chunk="${remaining:0:$last_newline}"
remaining="${remaining:$((last_newline+1))}"
else
chunk="${remaining:0:1900}"
remaining="${remaining:1900}"
fi
post_to_discord "$chunk"
fi
done
fi
echo "Posted daily recap to Discord"

173
.github/workflows/daily-pr-recap.yml vendored Normal file
View File

@@ -0,0 +1,173 @@
name: daily-pr-recap
on:
schedule:
# Run at 5pm EST (22:00 UTC, or 21:00 UTC during daylight saving)
- cron: "0 22 * * *"
workflow_dispatch: # Allow manual trigger for testing
jobs:
pr-recap:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Generate daily PR recap
id: recap
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh pr*": "allow",
"gh search*": "allow"
},
"webfetch": "deny",
"edit": "deny",
"write": "deny"
}
run: |
TODAY=$(date -u +%Y-%m-%d)
opencode run -m opencode/claude-sonnet-4-5 "Generate a daily PR activity recap for the OpenCode repository.
TODAY'S DATE: ${TODAY}
STEP 1: Gather PR data
Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}):
# Open PRs created today
gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
# Open PRs with activity today (updated today)
gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these:
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
This recap is specifically for COMMUNITY (external) contributions only.
STEP 2: For high-activity PRs, check comment counts
For promising PRs, run:
gh pr view [NUMBER] --repo ${{ github.repository }} --json comments --jq '[.comments[] | select(.author.login != \"copilot-pull-request-reviewer\" and .author.login != \"github-actions\")] | length'
IMPORTANT: When counting comments/activity, EXCLUDE these bot accounts:
- copilot-pull-request-reviewer
- github-actions
STEP 3: Identify what matters (ONLY from today's PRs)
**Bug Fixes From Today:**
- PRs with 'fix' or 'bug' in title created/updated today
- Small bug fixes (< 100 lines changed) that are easy to review
- Bug fixes from community contributors
**High Activity Today:**
- PRs with significant human comments today (excluding bots listed above)
- PRs with back-and-forth discussion today
**Quick Wins:**
- Small PRs (< 50 lines) that are approved or nearly approved
- PRs that just need a final review
STEP 4: Generate the recap
Create a structured recap:
===DISCORD_START===
**Daily PR Recap - ${TODAY}**
**New PRs Today**
[PRs opened today - group by type: bug fixes, features, etc.]
**Active PRs Today**
[PRs with activity/updates today - significant discussion]
**Quick Wins**
[Small PRs ready to merge]
===DISCORD_END===
STEP 5: Format for Discord
- Use Discord markdown (**, __, etc.)
- BE EXTREMELY CONCISE - surface what we might miss
- Use hyperlinked PR numbers with suppressed embeds: [#1234](<https://github.com/${{ github.repository }}/pull/1234>)
- Include PR author: [#1234](<url>) (@author)
- For bug fixes, add brief description of what it fixes
- Show line count for quick wins: \"(+15/-3 lines)\"
- HARD LIMIT: Keep under 1800 characters total
- Skip empty sections
- Focus on PRs that need human eyes
OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/pr_recap_raw.txt
# Extract only the Discord message between markers
sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/pr_recap_raw.txt | grep -v '===DISCORD' > /tmp/pr_recap.txt
echo "recap_file=/tmp/pr_recap.txt" >> $GITHUB_OUTPUT
- name: Post to Discord
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }}
run: |
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post"
cat /tmp/pr_recap.txt
exit 0
fi
# Read the recap
RECAP_RAW=$(cat /tmp/pr_recap.txt)
RECAP_LENGTH=${#RECAP_RAW}
echo "Recap length: ${RECAP_LENGTH} chars"
# Function to post a message to Discord
post_to_discord() {
local msg="$1"
local content=$(echo "$msg" | jq -Rs '.')
curl -s -H "Content-Type: application/json" \
-X POST \
-d "{\"content\": ${content}}" \
"$DISCORD_WEBHOOK_URL"
sleep 1
}
# If under limit, send as single message
if [ "$RECAP_LENGTH" -le 1950 ]; then
post_to_discord "$RECAP_RAW"
else
echo "Splitting into multiple messages..."
remaining="$RECAP_RAW"
while [ ${#remaining} -gt 0 ]; do
if [ ${#remaining} -le 1950 ]; then
post_to_discord "$remaining"
break
else
chunk="${remaining:0:1900}"
last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1)
if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then
chunk="${remaining:0:$last_newline}"
remaining="${remaining:$((last_newline+1))}"
else
chunk="${remaining:0:1900}"
remaining="${remaining:1900}"
fi
post_to_discord "$chunk"
fi
done
fi
echo "Posted daily PR recap to Discord"

116
.github/workflows/vouch-check-issue.yml vendored Normal file
View File

@@ -0,0 +1,116 @@
name: vouch-check-issue
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check if issue author is denounced
uses: actions/github-script@v7
with:
script: |
const author = context.payload.issue.user.login;
const issueNumber = context.payload.issue.number;
// Skip bots
if (author.endsWith('[bot]')) {
core.info(`Skipping bot: ${author}`);
return;
}
// Read the VOUCHED.td file via API (no checkout needed)
let content;
try {
const response = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/VOUCHED.td',
});
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
} catch (error) {
if (error.status === 404) {
core.info('No .github/VOUCHED.td file found, skipping check.');
return;
}
throw error;
}
// Parse the .td file for vouched and denounced users
const vouched = new Set();
const denounced = new Map();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const isDenounced = trimmed.startsWith('-');
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
if (!rest) continue;
const spaceIdx = rest.indexOf(' ');
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
// Handle platform:username or bare username
// Only match bare usernames or github: prefix (skip other platforms)
const colonIdx = handle.indexOf(':');
if (colonIdx !== -1) {
const platform = handle.slice(0, colonIdx).toLowerCase();
if (platform !== 'github') continue;
}
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
if (!username) continue;
if (isDenounced) {
denounced.set(username.toLowerCase(), reason);
continue;
}
vouched.add(username.toLowerCase());
}
// Check if the author is denounced
const reason = denounced.get(author.toLowerCase());
if (reason !== undefined) {
// Author is denounced — close the issue
const body = 'This issue has been automatically closed.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned',
});
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);
return;
}
// Author is positively vouched — add label
if (!vouched.has(author.toLowerCase())) {
core.info(`User ${author} is not denounced or vouched. Allowing issue.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: ['Vouched'],
});
core.info(`Added vouched label to issue #${issueNumber} from ${author}`);

114
.github/workflows/vouch-check-pr.yml vendored Normal file
View File

@@ -0,0 +1,114 @@
name: vouch-check-pr
on:
pull_request_target:
types: [opened]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check if PR author is denounced
uses: actions/github-script@v7
with:
script: |
const author = context.payload.pull_request.user.login;
const prNumber = context.payload.pull_request.number;
// Skip bots
if (author.endsWith('[bot]')) {
core.info(`Skipping bot: ${author}`);
return;
}
// Read the VOUCHED.td file via API (no checkout needed)
let content;
try {
const response = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/VOUCHED.td',
});
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
} catch (error) {
if (error.status === 404) {
core.info('No .github/VOUCHED.td file found, skipping check.');
return;
}
throw error;
}
// Parse the .td file for vouched and denounced users
const vouched = new Set();
const denounced = new Map();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const isDenounced = trimmed.startsWith('-');
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
if (!rest) continue;
const spaceIdx = rest.indexOf(' ');
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
// Handle platform:username or bare username
// Only match bare usernames or github: prefix (skip other platforms)
const colonIdx = handle.indexOf(':');
if (colonIdx !== -1) {
const platform = handle.slice(0, colonIdx).toLowerCase();
if (platform !== 'github') continue;
}
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
if (!username) continue;
if (isDenounced) {
denounced.set(username.toLowerCase(), reason);
continue;
}
vouched.add(username.toLowerCase());
}
// Check if the author is denounced
const reason = denounced.get(author.toLowerCase());
if (reason !== undefined) {
// Author is denounced — close the PR
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: 'This pull request has been automatically closed.',
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed',
});
core.info(`Closed PR #${prNumber} from denounced user ${author}`);
return;
}
// Author is positively vouched — add label
if (!vouched.has(author.toLowerCase())) {
core.info(`User ${author} is not denounced or vouched. Allowing PR.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['Vouched'],
});
core.info(`Added vouched label to PR #${prNumber} from ${author}`);

View File

@@ -0,0 +1,38 @@
name: vouch-manage-by-issue
on:
issue_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
issues: write
pull-requests: read
jobs:
manage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- uses: mitchellh/vouch/action/manage-by-issue@main
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}

View File

@@ -1,7 +1,7 @@
---
mode: primary
hidden: true
model: opencode/gpt-5.4-nano
model: opencode/minimax-m2.5
color: "#44BA81"
tools:
"*": false
@@ -14,30 +14,127 @@ Use your github-triage tool to triage issues.
This file is the source of truth for ownership/routing rules.
Assign issues by choosing the team with the strongest overlap. The github-triage tool will assign a random member from that team.
## Labels
Do not add labels to issues. Only assign an owner.
### windows
When calling github-triage, pass one of these team values: tui, desktop_web, core, inference, windows.
Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows.
## Teams
- Use if they mention WSL too
### TUI
#### perf
Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance.
Performance-related issues:
### Desktop / Web
- Slow performance
- High RAM usage
- High CPU usage
Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems.
**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness.
### Core
#### desktop
Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, documentation, and larger architectural features.
Desktop app issues:
### Inference
- `opencode web` command
- The desktop app itself
OpenCode Zen, OpenCode Go, and billing issues.
**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues.
### Windows
#### nix
Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows.
**Only** add if the issue explicitly mentions nix.
If the issue does not mention nix, do not add nix.
If the issue mentions nix, assign to `rekram1-node`.
#### zen
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
#### core
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
Examples:
- LSP server behavior
- Harness behavior (agent + tools)
- Feature requests for server behavior
- Agent context construction
- API endpoints
- Provider integration issues
- New, broken, or poor-quality models
#### acp
If the issue mentions acp support, assign acp label.
#### docs
Add if the issue requests better documentation or docs updates.
#### opentui
TUI issues potentially caused by our underlying TUI library:
- Keybindings not working
- Scroll speed issues (too fast/slow/laggy)
- Screen flickering
- Crashes with opentui in the log
**Do not** add for general TUI bugs.
When assigning to people here are the following rules:
Desktop / Web:
Use for desktop-labeled issues only.
- adamdotdevin
- iamdavidhill
- Brendonovich
- nexxeln
Zen:
ONLY assign if the issue will have the "zen" label.
- fwang
- MrMushrooooom
TUI (`packages/opencode/src/cli/cmd/tui/...`):
- thdxr for TUI UX/UI product decisions and interaction flow
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
Core (`packages/opencode/...`, excluding TUI subtree):
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
- rekram1-node for harness issues, provider issues, and other bug-squashing
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
Docs:
- R44VC0RP
Windows:
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
Determinism rules:
- If title + body does not contain "zen", do not add the "zen" label
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
- If title + body mentions nix/nixos, assign to `rekram1-node`
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
ACP:
- rekram1-node (assign any acp issues to rekram1-node)

View File

@@ -1,14 +1,16 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
const TEAM = {
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom"],
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: ["kommander", "rekram1-node", "simonklee"],
core: ["kitlangton", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
function pick<T>(items: readonly T[]) {
return items[Math.floor(Math.random() * items.length)]!
}
@@ -36,25 +38,79 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
export default tool({
description: `Use this tool to assign a GitHub issue.
description: `Use this tool to assign and/or label a GitHub issue.
Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`,
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: {
team: tool.schema
.enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]])
.describe("The owning team"),
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
.default([]),
},
async execute(args) {
const issue = getIssueNumber()
const owner = "anomalyco"
const repo = "opencode"
const assignee = pick(TEAM[args.team])
const results: string[] = []
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
const web = labels.includes("web")
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
const nix = /\bnix(os)?\b/.test(text)
if (labels.includes("nix") && !nix) {
labels = labels.filter((x) => x !== "nix")
results.push("Dropped label: nix (issue does not mention nix)")
}
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")
}
if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) {
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
}
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
}
if (assignee === "Hona" && !labels.includes("windows")) {
throw new Error("Only windows issues should be assigned to Hona")
}
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
throw new Error("Only docs issues should be assigned to R44VC0RP")
}
if (assignee === "kommander" && !labels.includes("opentui")) {
throw new Error("Only opentui issues should be assigned to kommander")
}
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
method: "POST",
body: JSON.stringify({ assignees: [assignee] }),
})
results.push(`Assigned @${assignee} to issue #${issue}`)
return `Assigned @${assignee} from ${args.team} to issue #${issue}`
if (labels.length > 0) {
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
method: "POST",
body: JSON.stringify({ labels }),
})
results.push(`Added labels: ${labels.join(", ")}`)
}
return results.join("\n")
},
})

View File

@@ -307,6 +307,19 @@
"@lydell/node-pty-win32-x64": "1.2.0-beta.10",
},
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "0.0.0",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.33",
@@ -396,6 +409,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -1572,6 +1586,8 @@
"@opencode-ai/desktop-electron": ["@opencode-ai/desktop-electron@workspace:packages/desktop-electron"],
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=",
"aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=",
"aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=",
"x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI="
"x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=",
"aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=",
"aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=",
"x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U="
}
}

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "0.0.0",
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"test": "bun test",
"typecheck": "tsgo --noEmit"
},
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}

View File

@@ -0,0 +1,246 @@
import { Database } from "bun:sqlite"
import { drizzle as drizzleBun, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
import { SQLiteCountBuilder } from "drizzle-orm/sqlite-core/query-builders/count"
import { SQLiteDeleteBase } from "drizzle-orm/sqlite-core/query-builders/delete"
import { SQLiteInsertBase } from "drizzle-orm/sqlite-core/query-builders/insert"
import { SQLiteRelationalQuery, SQLiteSyncRelationalQuery } from "drizzle-orm/sqlite-core/query-builders/_query"
import { SQLiteSelectBase } from "drizzle-orm/sqlite-core/query-builders/select"
import { SQLiteUpdateBase } from "drizzle-orm/sqlite-core/query-builders/update"
import type { PreparedQueryConfig, SQLiteSession, SQLiteTransaction, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
import { SQLitePreparedQuery } from "drizzle-orm/sqlite-core/session"
import type { DrizzleConfig } from "drizzle-orm/utils"
import { Cause, Effect, Exit, Schema } from "effect"
import * as Effectable from "effect/Effectable"
export class EffectDrizzleQueryError extends Schema.TaggedErrorClass<EffectDrizzleQueryError>()(
"EffectDrizzleQueryError",
{
query: Schema.String,
params: Schema.Array(Schema.Unknown),
cause: Schema.Unknown,
},
) {
override get message() {
return `Failed query: ${this.query}\nparams: ${this.params}`
}
constructor(params: { readonly query: string; readonly params: ReadonlyArray<unknown>; readonly cause: unknown }) {
super(params)
Error.captureStackTrace?.(this, EffectDrizzleQueryError)
}
}
export type EffectSQLiteDatabase<
TSchema extends Record<string, unknown> = Record<string, never>,
TRelations extends AnyRelations = EmptyRelations,
> = SQLiteBunDatabase<TSchema, TRelations> & {
readonly $client: Database
readonly withTransaction: <A, E, R>(
effect: Effect.Effect<A, E, R>,
config?: SQLiteTransactionConfig,
) => Effect.Effect<A, E, R>
}
export type MakeConfig<
TSchema extends Record<string, unknown> = Record<string, never>,
TRelations extends AnyRelations = EmptyRelations,
> = DrizzleConfig<TSchema, TRelations> & {
readonly client?: Database
}
type EffectLikeQuery<A = unknown> = {
readonly asEffect?: () => Effect.Effect<A, EffectDrizzleQueryError>
readonly toSQL?: () => { readonly sql: string; readonly params?: readonly unknown[] }
}
type PreparedLike<A = unknown> = EffectLikeQuery<A> & {
readonly execute: () => unknown
readonly getQuery?: () => { readonly sql: string; readonly params?: readonly unknown[] }
}
type SelectLike<A = unknown> = EffectLikeQuery<A> & {
readonly all: () => A
}
type GetLike<A = unknown> = EffectLikeQuery & {
readonly get: () => A
}
type MutationLike<A = unknown> = EffectLikeQuery<A> & {
readonly all: () => A
readonly run: () => A
readonly config?: { readonly returning?: unknown }
}
type CountLike = EffectLikeQuery<number> & {
readonly session: { readonly values: (sql: unknown) => unknown[][] }
readonly sql: unknown
}
class TransactionFailure extends Error {
constructor(readonly effectCause: Cause.Cause<unknown>) {
super("Effect transaction failed")
}
}
const queryInfo = (query: EffectLikeQuery | PreparedLike) => {
const info = "getQuery" in query && typeof query.getQuery === "function" ? query.getQuery() : query.toSQL?.()
return {
query: info?.sql ?? "<unknown>",
params: [...(info?.params ?? [])],
}
}
const queryError = (query: EffectLikeQuery | PreparedLike, cause: unknown) =>
new EffectDrizzleQueryError({
...queryInfo(query),
cause,
})
const fromSync = <A>(query: EffectLikeQuery, run: () => A) =>
Effect.try({
try: run,
catch: (cause) => queryError(query, cause),
})
const fromMutation = (query: MutationLike) => fromSync(query, () => (query.config?.returning ? query.all() : query.run()))
const fromCount = (query: CountLike) => fromSync(query, () => Number(query.session.values(query.sql)[0]?.[0] ?? 0))
export const getOne = <A>(query: GetLike<A>) => fromSync(query, () => query.get())
const fromExecuteResult = (result: unknown) => {
if (result && typeof result === "object" && "sync" in result && typeof result.sync === "function") {
return result.sync()
}
return result
}
const queryEffectProto = {
...Effectable.Prototype<Effect.Effect<unknown, EffectDrizzleQueryError> & EffectLikeQuery>({
label: "DrizzleSqliteQuery",
evaluate(this: EffectLikeQuery) {
return this.asEffect?.() ?? Effect.die("Drizzle SQLite query is missing asEffect()")
},
}),
commit(this: EffectLikeQuery) {
return this.asEffect?.() ?? Effect.die("Drizzle SQLite query is missing asEffect()")
},
}
const patchClass = <A>(ctor: { readonly prototype: object }, asEffect: (self: A) => Effect.Effect<unknown, EffectDrizzleQueryError>) => {
if (Object.prototype.hasOwnProperty.call(ctor.prototype, "asEffect")) return
Object.assign(ctor.prototype, queryEffectProto, {
asEffect(this: A) {
return asEffect(this)
},
})
}
// `patchClass` is idempotent via `hasOwnProperty` check, so calling this
// repeatedly is cheap. Patches are applied to Drizzle prototypes globally and
// survive any Database close/reopen cycle.
const patchQueryBuilders = () => {
patchClass(SQLitePreparedQuery, (query: PreparedLike) => fromSync(query, () => fromExecuteResult(query.execute())))
patchClass(SQLiteSelectBase, (query: SelectLike) => fromSync(query, () => query.all()))
patchClass(SQLiteInsertBase, fromMutation)
patchClass(SQLiteUpdateBase, fromMutation)
patchClass(SQLiteDeleteBase, fromMutation)
patchClass(SQLiteRelationalQuery, (query: EffectLikeQuery & { readonly executeRaw: () => unknown }) =>
fromSync(query, () => query.executeRaw()),
)
patchClass(SQLiteSyncRelationalQuery, (query: EffectLikeQuery & { readonly executeRaw: () => unknown }) =>
fromSync(query, () => query.executeRaw()),
)
patchClass(SQLiteCountBuilder, fromCount)
}
const attachTransaction = <
TSchema extends Record<string, unknown> = Record<string, never>,
TRelations extends AnyRelations = EmptyRelations,
>(db: SQLiteBunDatabase<TSchema, TRelations> & { readonly $client: Database }): EffectSQLiteDatabase<TSchema, TRelations> => {
const txStack: Array<SQLiteTransaction<"sync", void, TSchema, TRelations>> = []
const bound = new WeakMap<object, Map<PropertyKey, unknown>>()
const current = () => txStack.at(-1) ?? db
const runTransaction = (target: SQLiteBunDatabase<TSchema, TRelations> | SQLiteTransaction<"sync", void, TSchema, TRelations>) =>
target.transaction.bind(target) as (
transaction: (tx: SQLiteTransaction<"sync", void, TSchema, TRelations>) => unknown,
config?: SQLiteTransactionConfig,
) => unknown
const withTransaction = <A, E, R>(
effect: Effect.Effect<A, E, R>,
config?: SQLiteTransactionConfig,
): Effect.Effect<A, E, R> =>
Effect.context<R>().pipe(
Effect.flatMap((context) =>
Effect.sync(
() =>
runTransaction(current())((tx) => {
txStack.push(tx)
try {
const exit = Effect.runSyncExit(Effect.provideContext(effect, context))
if (Exit.isSuccess(exit)) return exit.value
throw new TransactionFailure(exit.cause)
} finally {
txStack.pop()
}
}, config) as A,
).pipe(
Effect.catchDefect((defect) =>
defect instanceof TransactionFailure ? Effect.failCause(defect.effectCause as Cause.Cause<E>) : Effect.die(defect),
),
),
),
)
return new Proxy(db, {
get(_target, property) {
if (property === "withTransaction") return withTransaction
if (property === "$client") return db.$client
const target = current()
const value = Reflect.get(target, property)
if (typeof value !== "function") return value
const methods = bound.get(target) ?? new Map<PropertyKey, unknown>()
bound.set(target, methods)
if (!methods.has(property)) methods.set(property, value.bind(target))
return methods.get(property)
},
}) as EffectSQLiteDatabase<TSchema, TRelations>
}
export const make = <
TSchema extends Record<string, unknown> = Record<string, never>,
TRelations extends AnyRelations = EmptyRelations,
>(config: MakeConfig<TSchema, TRelations> = {}): EffectSQLiteDatabase<TSchema, TRelations> => {
patchQueryBuilders()
return attachTransaction(
drizzleBun({
...config,
client: config.client ?? new Database(":memory:"),
}),
)
}
export const drizzle = make
declare module "drizzle-orm/query-promise" {
interface QueryPromise<T> extends Effect.Effect<T, EffectDrizzleQueryError> {
asEffect(): Effect.Effect<T, EffectDrizzleQueryError>
}
}
declare module "drizzle-orm/sqlite-core/session" {
interface SQLitePreparedQuery<T extends PreparedQueryConfig> extends Effect.Effect<T["execute"], EffectDrizzleQueryError> {
asEffect(): Effect.Effect<T["execute"], EffectDrizzleQueryError>
}
}
declare module "drizzle-orm/sqlite-core/query-builders/count" {
interface SQLiteCountBuilder<TSession extends SQLiteSession<any, any, any, any>>
extends Effect.Effect<number, EffectDrizzleQueryError> {
asEffect(): Effect.Effect<number, EffectDrizzleQueryError>
}
}

View File

@@ -0,0 +1,246 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { Database } from "bun:sqlite"
import { eq } from "drizzle-orm"
import { relations } from "drizzle-orm/_relations"
import { drizzle as drizzleBun } from "drizzle-orm/bun-sqlite"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Cause, Effect, Exit } from "effect"
import { EffectDrizzleQueryError, getOne, make, type EffectSQLiteDatabase } from "../src"
const users = sqliteTable("users", {
id: integer().primaryKey(),
name: text().notNull(),
})
const posts = sqliteTable("posts", {
id: integer().primaryKey(),
user_id: integer()
.notNull()
.references(() => users.id),
title: text().notNull(),
})
const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}))
const postsRelations = relations(posts, ({ one }) => ({
user: one(users, {
fields: [posts.user_id],
references: [users.id],
}),
}))
const schema = { users, posts, usersRelations, postsRelations }
let db: EffectSQLiteDatabase<typeof schema>
const testEffect = <A, E>(name: string, effect: () => Effect.Effect<A, E>) => test(name, () => Effect.runPromise(effect()))
beforeEach(() => {
db = make({ schema })
db.$client.run("PRAGMA foreign_keys = ON")
db.$client.run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
db.$client.run(
"CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), title TEXT NOT NULL)",
)
})
afterEach(() => {
db.$client.close()
})
describe("effect drizzle sqlite", () => {
test("keeps normal Drizzle Bun SQLite clients usable after patching", async () => {
const sqlite = new Database(":memory:")
try {
const normal = drizzleBun({ client: sqlite })
sqlite.run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
normal.insert(users).values({ id: 1, name: "Ada" }).run()
expect(normal.select().from(users).all()).toEqual([{ id: 1, name: "Ada" }])
expect(await normal.select().from(users)).toEqual([{ id: 1, name: "Ada" }])
} finally {
sqlite.close()
}
})
testEffect("makes select/insert/update/delete query builders yieldable Effects", () =>
Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
yield* db.insert(users).values({ id: 2, name: "Grace" })
const selected = yield* db.select().from(users).orderBy(users.id)
expect(selected).toEqual([
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" },
])
const updated = yield* db.update(users).set({ name: "Lovelace" }).where(eq(users.id, 1)).returning()
expect(updated).toEqual([{ id: 1, name: "Lovelace" }])
const deleted = yield* db.delete(users).where(eq(users.id, 2)).returning({ id: users.id })
expect(deleted).toEqual([{ id: 2 }])
expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Lovelace" }])
}),
)
testEffect("supports direct Effect combinators on queries", () =>
Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
expect(
yield* (db.select().from(users) as Effect.Effect<Array<{ readonly name: string }>, EffectDrizzleQueryError>).pipe(
Effect.map((rows) => rows.map((row) => row.name)),
),
).toEqual(["Ada"])
}),
)
testEffect("supports relational query builders", () =>
Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
yield* db.insert(posts).values({ id: 1, user_id: 1, title: "Notes" })
expect(
yield* db._query.users.findMany({
with: {
posts: true,
},
}),
).toEqual([
{
id: 1,
name: "Ada",
posts: [{ id: 1, user_id: 1, title: "Notes" }],
},
])
}),
)
testEffect("runs synchronous Effect programs inside transactions", () =>
Effect.gen(function* () {
yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
return yield* db.select().from(users)
}).pipe(db.withTransaction)
expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Ada" }])
const exit = yield* Effect.exit(
Effect.gen(function* () {
yield* db.insert(users).values({ id: 2, name: "Grace" })
return yield* Effect.fail("rollback")
}).pipe(db.withTransaction),
)
expect(Exit.isFailure(exit)).toBe(true)
expect(yield* db.select().from(users).orderBy(users.id)).toEqual([{ id: 1, name: "Ada" }])
}),
)
testEffect("supports pipeable transactions using the same database service", () =>
Effect.gen(function* () {
const exit = yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
return yield* Effect.fail("rollback")
}).pipe(db.withTransaction, Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
expect(yield* db.select().from(users)).toEqual([])
yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 2, name: "Grace" })
expect(yield* db.$count(users)).toBe(1)
}).pipe(db.withTransaction)
expect(yield* db.select().from(users)).toEqual([{ id: 2, name: "Grace" }])
}),
)
testEffect("supports count builders and prepared queries", () =>
Effect.gen(function* () {
yield* db.insert(users).values([
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" },
])
expect(yield* db.$count(users)).toBe(2)
const prepared = db.select().from(users).orderBy(users.id).prepare()
expect(yield* prepared).toEqual([
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" },
])
}),
)
testEffect("supports single-row select effects", () =>
Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
expect(yield* getOne(db.select().from(users).where(eq(users.id, 1)))).toEqual({ id: 1, name: "Ada" })
expect(yield* getOne(db.select().from(users).where(eq(users.id, 2)))).toBeUndefined()
}),
)
testEffect("nested pipeable transactions commit or roll back with the outer transaction", () =>
Effect.gen(function* () {
yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 2, name: "Grace" })
}).pipe(db.withTransaction)
}).pipe(db.withTransaction)
expect(yield* db.select().from(users).orderBy(users.id)).toEqual([
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" },
])
const exit = yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 3, name: "Katherine" })
yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 4, name: "Dorothy" })
return yield* Effect.fail("inner rollback")
}).pipe(db.withTransaction)
}).pipe(db.withTransaction, Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
expect(yield* db.select().from(users).orderBy(users.id)).toEqual([
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" },
])
}),
)
testEffect("defects inside transactions roll back and stay defects", () =>
Effect.gen(function* () {
const exit = yield* Effect.gen(function* () {
yield* db.insert(users).values({ id: 1, name: "Ada" })
return yield* Effect.die("boom")
}).pipe(db.withTransaction, Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(exit.cause.reasons.some(Cause.isDieReason)).toBe(true)
}
expect(yield* db.select().from(users)).toEqual([])
}),
)
testEffect("wraps query failures with query text and parameters", () =>
Effect.gen(function* () {
const exit = yield* Effect.exit(db.insert(posts).values({ id: 1, user_id: 404, title: "Missing" }))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = exit.cause.reasons.filter(Cause.isFailReason)[0]?.error
expect(error).toBeInstanceOf(EffectDrizzleQueryError)
expect((error as EffectDrizzleQueryError).query).toContain("insert into")
expect((error as EffectDrizzleQueryError).params).toEqual([1, 404, "Missing"])
}
}),
)
})

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"types": ["bun"],
"noUncheckedIndexedAccess": false,
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
}
}

View File

@@ -110,6 +110,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",

View File

@@ -182,7 +182,7 @@ type Runtime = {
Todo: (typeof import("../src/session/todo"))["Todo"]
Worktree: (typeof import("../src/worktree"))["Worktree"]
Project: (typeof import("../src/project/project"))["Project"]
Tui: typeof import("../src/server/shared/tui-control")
Tui: typeof import("../src/server/routes/instance/tui")
disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"]
tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"]
resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"]
@@ -203,7 +203,7 @@ function runtime() {
const todo = await import("../src/session/todo")
const worktree = await import("../src/worktree")
const project = await import("../src/project/project")
const tui = await import("../src/server/shared/tui-control")
const tui = await import("../src/server/routes/instance/tui")
const fixture = await import("../test/fixture/fixture")
const db = await import("../test/fixture/db")
return {
@@ -1506,7 +1506,7 @@ const main = Effect.gen(function* () {
const options = parseOptions(Bun.argv.slice(2))
const modules = yield* Effect.promise(() => runtime())
const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono()))
const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapi()))
const selected = scenarios.filter((scenario) => matches(options, scenario))
const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario)))
const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario)))

View File

@@ -6,7 +6,6 @@ import { ACP } from "@/acp/agent"
import { Server } from "@/server/server"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "@opencode-ai/core/flag/flag"
const log = Log.create({ service: "acp-command" })
@@ -27,13 +26,6 @@ export const AcpCommand = effectCmd({
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,
headers: Flag.OPENCODE_SERVER_PASSWORD
? {
Authorization: `Basic ${Buffer.from(
`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`,
).toString("base64")}`,
}
: undefined,
})
const input = new WritableStream<Uint8Array>({

View File

@@ -1,5 +1,6 @@
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { UI } from "../ui"
import { Global } from "@opencode-ai/core/global"
import { Agent } from "../../agent/agent"
@@ -65,166 +66,170 @@ const AgentCreateCommand = effectCmd({
const maybeCtx = yield* InstanceRef
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
const agentSvc = yield* Agent.Service
yield* Effect.promise(async () => {
const cliPath = args.path
const cliDescription = args.description
const cliMode = args.mode as AgentMode | undefined
const perms = args.permissions
const cliPath = args.path
const cliDescription = args.description
const cliMode = args.mode as AgentMode | undefined
const perms = args.permissions
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
if (!isFullyNonInteractive) {
UI.empty()
prompts.intro("Create agent")
}
if (!isFullyNonInteractive) {
UI.empty()
prompts.intro("Create agent")
}
const project = ctx.project
const project = ctx.project
// Determine scope/path
let targetPath: string
if (cliPath) {
targetPath = path.join(cliPath, "agent")
} else {
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
// Determine scope/path
let targetPath: string
if (cliPath) {
targetPath = path.join(cliPath, "agent")
} else {
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: "project" as const,
hint: ctx.worktree,
},
{
label: "Global",
value: "global" as const,
hint: Global.Path.config,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
}
targetPath = path.join(
scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"),
"agent",
)
}
// Get description
let description: string
if (cliDescription) {
description = cliDescription
} else {
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
description = query
}
// Generate agent
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const model = args.model ? Provider.parseModel(args.model) : undefined
const generated = await AppRuntime.runPromise(
Agent.Service.use((svc) => svc.generate({ description, model })),
).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
if (isFullyNonInteractive) process.exit(1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
// Select permissions to allow
let selected: string[]
if (perms !== undefined) {
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
} else {
const result = await prompts.multiselect({
message: "Select permissions to allow (Space to toggle)",
options: AVAILABLE_PERMISSIONS.map((permission) => ({
label: permission,
value: permission,
})),
initialValues: AVAILABLE_PERMISSIONS,
})
if (prompts.isCancel(result)) throw new UI.CancelledError()
selected = result
}
// Get mode
let mode: AgentMode
if (cliMode) {
mode = cliMode
} else {
const modeResult = await prompts.select({
message: "Agent mode",
options: [
{
label: "Current project",
value: "project" as const,
hint: ctx.worktree,
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
},
{
label: "Global",
value: "global" as const,
hint: Global.Path.config,
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
},
],
initialValue: "all" as const,
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
mode = modeResult
}
targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent")
}
// Get description
let description: string
if (cliDescription) {
description = cliDescription
} else {
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
description = query
}
// Generate agent
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const model = args.model ? Provider.parseModel(args.model) : undefined
const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
if (isFullyNonInteractive) process.exit(1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
// Select permissions to allow
let selected: string[]
if (perms !== undefined) {
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
} else {
const result = await prompts.multiselect({
message: "Select permissions to allow (Space to toggle)",
options: AVAILABLE_PERMISSIONS.map((permission) => ({
label: permission,
value: permission,
})),
initialValues: AVAILABLE_PERMISSIONS,
})
if (prompts.isCancel(result)) throw new UI.CancelledError()
selected = result
}
// Get mode
let mode: AgentMode
if (cliMode) {
mode = cliMode
} else {
const modeResult = await prompts.select({
message: "Agent mode",
options: [
{
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
},
{
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
},
],
initialValue: "all" as const,
})
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
mode = modeResult
}
// Build permissions config — deny anything not explicitly selected.
const permissions: Record<string, "deny"> = {}
for (const permission of AVAILABLE_PERMISSIONS) {
if (!selected.includes(permission)) {
permissions[permission] = "deny"
// Build permissions config — deny anything not explicitly selected.
const permissions: Record<string, "deny"> = {}
for (const permission of AVAILABLE_PERMISSIONS) {
if (!selected.includes(permission)) {
permissions[permission] = "deny"
}
}
}
// Build frontmatter
const frontmatter: {
description: string
mode: AgentMode
permission?: Record<string, "deny">
} = {
description: generated.whenToUse,
mode,
}
if (Object.keys(permissions).length > 0) {
frontmatter.permission = permissions
}
// Build frontmatter
const frontmatter: {
description: string
mode: AgentMode
permission?: Record<string, "deny">
} = {
description: generated.whenToUse,
mode,
}
if (Object.keys(permissions).length > 0) {
frontmatter.permission = permissions
}
// Write file
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(targetPath, `${generated.identifier}.md`)
// Write file
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(targetPath, `${generated.identifier}.md`)
await fs.mkdir(targetPath, { recursive: true })
await fs.mkdir(targetPath, { recursive: true })
if (await Filesystem.exists(filePath)) {
if (isFullyNonInteractive) {
console.error(`Error: Agent file already exists: ${filePath}`)
process.exit(1)
}
prompts.log.error(`Agent file already exists: ${filePath}`)
throw new UI.CancelledError()
}
await Filesystem.write(filePath, content)
if (await Filesystem.exists(filePath)) {
if (isFullyNonInteractive) {
console.error(`Error: Agent file already exists: ${filePath}`)
process.exit(1)
console.log(filePath)
} else {
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
}
prompts.log.error(`Agent file already exists: ${filePath}`)
throw new UI.CancelledError()
}
await Filesystem.write(filePath, content)
if (isFullyNonInteractive) {
console.log(filePath)
} else {
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
}
})
}),
})

View File

@@ -1,10 +1,5 @@
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Flag } from "@opencode-ai/core/flag/flag"
import os from "os"
import { Duration, Effect } from "effect"
import { Config } from "@/config/config"
import { ConfigPlugin } from "@/config/plugin"
import { effectCmd } from "../../effect-cmd"
import { cmd } from "../cmd"
import { ConfigCommand } from "./config"
@@ -31,7 +26,6 @@ export const DebugCommand = cmd({
.command(SnapshotCommand)
.command(StartupCommand)
.command(AgentCommand)
.command(InfoCommand)
.command(PathsCommand)
.command(WaitCommand)
.demandCommand(),
@@ -46,34 +40,6 @@ const WaitCommand = effectCmd({
}),
})
const InfoCommand = effectCmd({
command: "info",
describe: "show debug information",
handler: Effect.fn("Cli.debug.info")(function* () {
const config = yield* Config.Service.use((cfg) => cfg.get())
const termProgram = process.env.TERM_PROGRAM
? `${process.env.TERM_PROGRAM}${process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""}`
: undefined
const terminal = [termProgram, process.env.TERM].filter((item): item is string => Boolean(item)).join(" / ")
console.log(`opencode version: ${InstallationVersion}`)
console.log(`os: ${os.type()} ${os.release()} ${os.arch()}`)
console.log(`terminal: ${terminal || "unknown"}`)
console.log("plugins:")
if (Flag.OPENCODE_PURE) {
console.log("external plugins disabled (--pure)")
return
}
if (!config.plugin_origins?.length) {
console.log("none")
return
}
for (const plugin of config.plugin_origins) {
console.log(`- ${ConfigPlugin.pluginSpecifier(plugin.spec)}`)
}
}),
})
const PathsCommand = cmd({
command: "paths",
describe: "show global paths (data, config, cache, state)",

View File

@@ -1,28 +1,22 @@
import { Server } from "../../server/server"
import { PublicApi } from "../../server/routes/instance/httpapi/public"
import type { CommandModule } from "yargs"
import { OpenApi } from "effect/unstable/httpapi"
type Args = {
httpapi: boolean
hono: boolean
}
export const GenerateCommand = {
command: "generate",
builder: (yargs) =>
yargs
.option("httpapi", {
type: "boolean",
default: false,
description:
"Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)",
})
.option("hono", {
type: "boolean",
default: false,
description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)",
}),
yargs.option("httpapi", {
type: "boolean",
default: false,
description: "Generate OpenAPI from the experimental Effect HttpApi contract",
}),
handler: async (args) => {
const specs = args.hono ? await Server.openapiHono() : await Server.openapi()
const specs = args.httpapi ? OpenApi.fromApi(PublicApi) : await Server.openapi()
for (const item of Object.values(specs.paths)) {
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method]

View File

@@ -19,6 +19,7 @@ import { Global } from "@opencode-ai/core/global"
import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "@/util/filesystem"
import { Bus } from "../../bus"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
function getAuthStatusIcon(status: MCP.AuthStatus): string {
@@ -439,158 +440,158 @@ export const McpAddCommand = effectCmd({
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
yield* Effect.promise(async () => {
UI.empty()
prompts.intro("Add MCP server")
UI.empty()
prompts.intro("Add MCP server")
const project = ctx.project
const project = ctx.project
// Resolve config paths eagerly for hints
const [projectConfigPath, globalConfigPath] = await Promise.all([
resolveConfigPath(ctx.worktree),
resolveConfigPath(Global.Path.config, true),
])
// Resolve config paths eagerly for hints
const [projectConfigPath, globalConfigPath] = await Promise.all([
resolveConfigPath(ctx.worktree),
resolveConfigPath(Global.Path.config, true),
])
// Determine scope
let configPath = globalConfigPath
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
// Determine scope
let configPath = globalConfigPath
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: projectConfigPath,
hint: projectConfigPath,
},
{
label: "Global",
value: globalConfigPath,
hint: globalConfigPath,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
configPath = scopeResult
}
const name = await prompts.text({
message: "Enter MCP server name",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(name)) throw new UI.CancelledError()
const type = await prompts.select({
message: "Select MCP server type",
options: [
{
label: "Current project",
value: projectConfigPath,
hint: projectConfigPath,
label: "Local",
value: "local",
hint: "Run a local command",
},
{
label: "Global",
value: globalConfigPath,
hint: globalConfigPath,
label: "Remote",
value: "remote",
hint: "Connect to a remote URL",
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
configPath = scopeResult
}
if (prompts.isCancel(type)) throw new UI.CancelledError()
const name = await prompts.text({
message: "Enter MCP server name",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(name)) throw new UI.CancelledError()
if (type === "local") {
const command = await prompts.text({
message: "Enter command to run",
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(command)) throw new UI.CancelledError()
const type = await prompts.select({
message: "Select MCP server type",
options: [
{
label: "Local",
value: "local",
hint: "Run a local command",
},
{
label: "Remote",
value: "remote",
hint: "Connect to a remote URL",
},
],
})
if (prompts.isCancel(type)) throw new UI.CancelledError()
const mcpConfig: ConfigMCP.Info = {
type: "local",
command: command.split(" "),
}
if (type === "local") {
const command = await prompts.text({
message: "Enter command to run",
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(command)) throw new UI.CancelledError()
const mcpConfig: ConfigMCP.Info = {
type: "local",
command: command.split(" "),
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
prompts.outro("MCP server added successfully")
return
}
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
prompts.outro("MCP server added successfully")
return
}
if (type === "remote") {
const url = await prompts.text({
message: "Enter MCP server URL",
placeholder: "e.g., https://example.com/mcp",
validate: (x) => {
if (!x) return "Required"
if (x.length === 0) return "Required"
const isValid = URL.canParse(x)
return isValid ? undefined : "Invalid URL"
},
})
if (prompts.isCancel(url)) throw new UI.CancelledError()
if (type === "remote") {
const url = await prompts.text({
message: "Enter MCP server URL",
placeholder: "e.g., https://example.com/mcp",
validate: (x) => {
if (!x) return "Required"
if (x.length === 0) return "Required"
const isValid = URL.canParse(x)
return isValid ? undefined : "Invalid URL"
},
})
if (prompts.isCancel(url)) throw new UI.CancelledError()
const useOAuth = await prompts.confirm({
message: "Does this server require OAuth authentication?",
initialValue: false,
})
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
let mcpConfig: ConfigMCP.Info
if (useOAuth) {
const hasClientId = await prompts.confirm({
message: "Do you have a pre-registered client ID?",
const useOAuth = await prompts.confirm({
message: "Does this server require OAuth authentication?",
initialValue: false,
})
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
if (hasClientId) {
const clientId = await prompts.text({
message: "Enter client ID",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
let mcpConfig: ConfigMCP.Info
const hasSecret = await prompts.confirm({
message: "Do you have a client secret?",
if (useOAuth) {
const hasClientId = await prompts.confirm({
message: "Do you have a pre-registered client ID?",
initialValue: false,
})
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
let clientSecret: string | undefined
if (hasSecret) {
const secret = await prompts.password({
message: "Enter client secret",
if (hasClientId) {
const clientId = await prompts.text({
message: "Enter client ID",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(secret)) throw new UI.CancelledError()
clientSecret = secret
}
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
mcpConfig = {
type: "remote",
url,
oauth: {
clientId,
...(clientSecret && { clientSecret }),
},
const hasSecret = await prompts.confirm({
message: "Do you have a client secret?",
initialValue: false,
})
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
let clientSecret: string | undefined
if (hasSecret) {
const secret = await prompts.password({
message: "Enter client secret",
})
if (prompts.isCancel(secret)) throw new UI.CancelledError()
clientSecret = secret
}
mcpConfig = {
type: "remote",
url,
oauth: {
clientId,
...(clientSecret && { clientSecret }),
},
}
} else {
mcpConfig = {
type: "remote",
url,
oauth: {},
}
}
} else {
mcpConfig = {
type: "remote",
url,
oauth: {},
}
}
} else {
mcpConfig = {
type: "remote",
url,
}
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
}
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
}
prompts.outro("MCP server added successfully")
prompts.outro("MCP server added successfully")
})
}),
})
@@ -605,171 +606,178 @@ export const McpDebugCommand = effectCmd({
demandOption: true,
}),
handler: Effect.fn("Cli.mcp.debug")(function* (args) {
const config = yield* Config.Service.use((cfg) => cfg.get())
const mcp = yield* MCP.Service
const auth = yield* McpAuth.Service
yield* Effect.promise(async () => {
UI.empty()
prompts.intro("MCP OAuth Debug")
UI.empty()
prompts.intro("MCP OAuth Debug")
const mcpServers = config.mcp ?? {}
const serverName = args.name
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const mcpServers = config.mcp ?? {}
const serverName = args.name
const serverConfig = mcpServers[serverName]
if (!serverConfig) {
prompts.log.error(`MCP server not found: ${serverName}`)
prompts.outro("Done")
return
}
if (!isMcpRemote(serverConfig)) {
prompts.log.error(`MCP server ${serverName} is not a remote server`)
prompts.outro("Done")
return
}
if (serverConfig.oauth === false) {
prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
prompts.outro("Done")
return
}
prompts.log.info(`Server: ${serverName}`)
prompts.log.info(`URL: ${serverConfig.url}`)
// Check stored auth status — services already in hand, run inline.
const { authStatus, entry } = await Effect.runPromise(
Effect.all({
authStatus: mcp.getAuthStatus(serverName),
entry: auth.get(serverName),
}),
)
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
if (entry?.tokens) {
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
if (entry.tokens.expiresAt) {
const expiresDate = new Date(entry.tokens.expiresAt * 1000)
const isExpired = entry.tokens.expiresAt < Date.now() / 1000
prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
const serverConfig = mcpServers[serverName]
if (!serverConfig) {
prompts.log.error(`MCP server not found: ${serverName}`)
prompts.outro("Done")
return
}
if (entry.tokens.refreshToken) {
prompts.log.info(` Refresh token: present`)
}
}
if (entry?.clientInfo) {
prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
if (entry.clientInfo.clientSecretExpiresAt) {
const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
}
}
const spinner = prompts.spinner()
spinner.start("Testing connection...")
if (!isMcpRemote(serverConfig)) {
prompts.log.error(`MCP server ${serverName} is not a remote server`)
prompts.outro("Done")
return
}
// Test basic HTTP connectivity first
try {
const response = await fetch(serverConfig.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "opencode-debug", version: InstallationVersion },
},
id: 1,
if (serverConfig.oauth === false) {
prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
prompts.outro("Done")
return
}
prompts.log.info(`Server: ${serverName}`)
prompts.log.info(`URL: ${serverConfig.url}`)
// Check stored auth status
const { authStatus, entry } = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const auth = yield* McpAuth.Service
return {
authStatus: yield* mcp.getAuthStatus(serverName),
entry: yield* auth.get(serverName),
}
}),
})
)
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
// Check for WWW-Authenticate header
const wwwAuth = response.headers.get("www-authenticate")
if (wwwAuth) {
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
if (entry?.tokens) {
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
if (entry.tokens.expiresAt) {
const expiresDate = new Date(entry.tokens.expiresAt * 1000)
const isExpired = entry.tokens.expiresAt < Date.now() / 1000
prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
}
if (entry.tokens.refreshToken) {
prompts.log.info(` Refresh token: present`)
}
}
if (entry?.clientInfo) {
prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
if (entry.clientInfo.clientSecretExpiresAt) {
const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
}
}
if (response.status === 401) {
prompts.log.warn("Server returned 401 Unauthorized")
const spinner = prompts.spinner()
spinner.start("Testing connection...")
// Try to discover OAuth metadata
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
const authProvider = new McpOAuthProvider(
serverName,
serverConfig.url,
{
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
// Test basic HTTP connectivity first
try {
const response = await fetch(serverConfig.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
{
onRedirect: async () => {},
},
auth,
)
prompts.log.info("Testing OAuth flow (without completing authorization)...")
// Try creating transport with auth provider to trigger discovery
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
authProvider,
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "opencode-debug", version: InstallationVersion },
},
id: 1,
}),
})
try {
const client = new Client({
name: "opencode-debug",
version: InstallationVersion,
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
// Check for WWW-Authenticate header
const wwwAuth = response.headers.get("www-authenticate")
if (wwwAuth) {
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
}
if (response.status === 401) {
prompts.log.warn("Server returned 401 Unauthorized")
// Try to discover OAuth metadata
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
const auth = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* McpAuth.Service
}),
)
const authProvider = new McpOAuthProvider(
serverName,
serverConfig.url,
{
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async () => {},
},
auth,
)
prompts.log.info("Testing OAuth flow (without completing authorization)...")
// Try creating transport with auth provider to trigger discovery
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
authProvider,
})
await client.connect(transport)
prompts.log.success("Connection successful (already authenticated)")
await client.close()
} catch (error) {
if (error instanceof UnauthorizedError) {
prompts.log.info(`OAuth flow triggered: ${error.message}`)
// Check if dynamic registration would be attempted
const clientInfo = await authProvider.clientInformation()
if (clientInfo) {
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
try {
const client = new Client({
name: "opencode-debug",
version: InstallationVersion,
})
await client.connect(transport)
prompts.log.success("Connection successful (already authenticated)")
await client.close()
} catch (error) {
if (error instanceof UnauthorizedError) {
prompts.log.info(`OAuth flow triggered: ${error.message}`)
// Check if dynamic registration would be attempted
const clientInfo = await authProvider.clientInformation()
if (clientInfo) {
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
} else {
prompts.log.info("No client ID - dynamic registration will be attempted")
}
} else {
prompts.log.info("No client ID - dynamic registration will be attempted")
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
}
} else {
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
}
} else if (response.status >= 200 && response.status < 300) {
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
const body = await response.text()
try {
const json = JSON.parse(body)
if (json.result?.serverInfo) {
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
}
} catch {
// Not JSON, ignore
}
} else {
prompts.log.warn(`Unexpected status: ${response.status}`)
const body = await response.text().catch(() => "")
if (body) {
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
}
}
} else if (response.status >= 200 && response.status < 300) {
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
const body = await response.text()
try {
const json = JSON.parse(body)
if (json.result?.serverInfo) {
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
}
} catch {
// Not JSON, ignore
}
} else {
prompts.log.warn(`Unexpected status: ${response.status}`)
const body = await response.text().catch(() => "")
if (body) {
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
}
} catch (error) {
spinner.stop("Connection failed", 1)
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
}
} catch (error) {
spinner.stop("Connection failed", 1)
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
}
prompts.outro("Debug complete")
prompts.outro("Debug complete")
})
}),
})

View File

@@ -239,47 +239,50 @@ export const ProvidersListCommand = effectCmd({
// Lists global credentials + provider env vars; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.list")(function* (_args) {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service
yield* Effect.promise(async () => {
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
const database = await getModels()
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
prompts.outro(`${results.length} credentials`)
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
}
}
}
if (activeEnvVars.length > 0) {
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(await Effect.runPromise(authSvc.all()))
const database = await Effect.runPromise(modelsDev.get())
prompts.intro("Environment")
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
prompts.outro(`${results.length} credentials`)
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
}
}
}
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
})
}),
})
@@ -304,186 +307,188 @@ export const ProvidersLoginCommand = effectCmd({
type: "string",
}),
handler: Effect.fn("Cli.providers.login")(function* (args) {
const cfgSvc = yield* Config.Service
const pluginSvc = yield* Plugin.Service
yield* Effect.promise(async () => {
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
auth: { command: string[]; env: string }
}
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe",
stderr: "inherit",
})
if (!proc.stdout) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
await put(url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + url)
prompts.outro("Done")
return
}
await refreshModels().catch(() => {})
const config = await Effect.runPromise(cfgSvc.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const providers = await getModels().then((x) => {
const filtered: Record<string, (typeof x)[string]> = {}
for (const [key, value] of Object.entries(x)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
auth: { command: string[]; env: string }
}
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe",
})
if (!proc.stdout) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
await put(url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + url)
prompts.outro("Done")
return
}
return filtered
})
const hooks = await Effect.runPromise(pluginSvc.list())
await refreshModels().catch(() => {})
const priority: Record<string, number> = {
opencode: 0,
openai: 1,
"github-copilot": 2,
google: 3,
anthropic: 4,
openrouter: 5,
vercel: 6,
}
const pluginProviders = resolvePluginProviders({
hooks,
existingProviders: providers,
disabled,
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
})
const options = [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const providers = await getModels().then((x) => {
const filtered: Record<string, (typeof x)[string]> = {}
for (const [key, value] of Object.entries(x)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
return filtered
})
const hooks = await AppRuntime.runPromise(
Effect.gen(function* () {
const plugin = yield* Plugin.Service
return yield* plugin.list()
}),
)
const priority: Record<string, number> = {
opencode: 0,
openai: 1,
"github-copilot": 2,
google: 3,
anthropic: 4,
openrouter: 5,
vercel: 6,
}
const pluginProviders = resolvePluginProviders({
hooks,
existingProviders: providers,
disabled,
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
})
const options = [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: {
opencode: "recommended",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
),
map((x) => ({
...pluginProviders.map((x) => ({
label: x.name,
value: x.id,
hint: {
opencode: "recommended",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
hint: "plugin",
})),
),
...pluginProviders.map((x) => ({
label: x.name,
value: x.id,
hint: "plugin",
})),
]
]
let provider: string
if (args.provider) {
const input = args.provider
const byID = options.find((x) => x.value === input)
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
const match = byID ?? byName
if (!match) {
prompts.log.error(`Unknown provider "${input}"`)
process.exit(1)
let provider: string
if (args.provider) {
const input = args.provider
const byID = options.find((x) => x.value === input)
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
const match = byID ?? byName
if (!match) {
prompts.log.error(`Unknown provider "${input}"`)
process.exit(1)
}
provider = match.value
} else {
const selected = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...options,
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
provider = selected as string
}
provider = match.value
} else {
const selected = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...options,
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
provider = selected as string
}
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
if (handled) return
}
if (provider === "other") {
const custom = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
if (handled) return
}
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "other") {
const custom = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
if (handled) return
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
prompts.log.info(
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
)
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
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()
await put(provider, {
type: "api",
key,
})
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
prompts.outro("Done")
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
prompts.log.info(
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
)
}
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()
await put(provider, {
type: "api",
key,
})
prompts.outro("Done")
})
}),
})
@@ -494,28 +499,36 @@ export const ProvidersLogoutCommand = effectCmd({
// Removes a global auth credential; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service
yield* Effect.promise(async () => {
UI.empty()
const credentials: Array<[string, Auth.Info]> = Object.entries(await Effect.runPromise(authSvc.all()))
prompts.intro("Remove credential")
if (credentials.length === 0) {
prompts.log.error("No credentials found")
return
}
const database = await Effect.runPromise(modelsDev.get())
const selected = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
const providerID = selected as string
await Effect.runPromise(authSvc.remove(providerID))
prompts.outro("Logout successful")
UI.empty()
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
prompts.intro("Remove credential")
if (credentials.length === 0) {
prompts.log.error("No credentials found")
return
}
const database = await getModels()
const selected = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
const providerID = selected as string
await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove(providerID)
}),
)
prompts.outro("Logout successful")
})
}),
})

View File

@@ -3,7 +3,6 @@ import { Effect, Schema } from "effect"
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
import { InstanceStore } from "@/project/instance-store"
import { InstanceRef } from "@/effect/instance-ref"
import { Instance } from "@/project/instance"
import { cmd, type WithDoubleDash } from "./cmd/cmd"
/**
@@ -83,21 +82,17 @@ export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
return
}
const directory = opts.directory?.(args) ?? process.cwd()
// Two-phase: load ctx, then run body inside Instance.current ALS.
// Effect's InstanceRef is provided via fiber context, but that context is
// lost across `await` inside `Effect.promise(async () => ...)` callbacks
// — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())`
// there, attach() falls back to Instance.current ALS, which Node preserves
// across awaits. Matches the pre-effectCmd `bootstrap()` behavior.
const { store, ctx } = await AppRuntime.runPromise(
InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
await AppRuntime.runPromise(
InstanceStore.Service.use((store) =>
store.provide(
{ directory },
Effect.gen(function* () {
const ctx = yield* InstanceRef
const body = opts.handler(args)
return ctx ? yield* body.pipe(Effect.ensuring(store.dispose(ctx))) : yield* body
}),
),
),
)
try {
await Instance.restore(ctx, () =>
AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))),
)
} finally {
await AppRuntime.runPromise(store.dispose(ctx))
}
},
})

View File

@@ -24,7 +24,6 @@ const fail = (err: unknown) =>
text: () => "",
stdout: Buffer.alloc(0),
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
truncated: false,
}) satisfies Result
export type Kind = "added" | "deleted" | "modified"
@@ -46,28 +45,16 @@ export type Stat = {
readonly deletions: number
}
export type Patch = {
readonly text: string
readonly truncated: boolean
}
export interface PatchOptions {
readonly context?: number
readonly maxOutputBytes?: number
}
export interface Result {
readonly exitCode: number
readonly text: () => string
readonly stdout: Buffer
readonly stderr: Buffer
readonly truncated: boolean
}
export interface Options {
readonly cwd: string
readonly env?: Record<string, string>
readonly maxOutputBytes?: number
}
export interface Interface {
@@ -81,10 +68,6 @@ export interface Interface {
readonly status: (cwd: string) => Effect.Effect<Item[]>
readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
readonly patch: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly statUntracked: (cwd: string, file: string) => Effect.Effect<Stat | undefined>
}
const kind = (code: string): Kind => {
@@ -113,31 +96,15 @@ export const layer = Layer.effect(
stderr: "pipe",
})
const handle = yield* spawner.spawn(proc)
const collect = (stream: typeof handle.stdout) =>
Stream.runFold(
stream,
() => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
(acc, chunk) => {
if (opts.maxOutputBytes === undefined) {
acc.chunks.push(chunk)
acc.bytes += chunk.length
return acc
}
const remaining = opts.maxOutputBytes - acc.bytes
if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
acc.bytes += chunk.length
acc.truncated = acc.truncated || acc.bytes > opts.maxOutputBytes
return acc
},
).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated })))
const [stdout, stderr] = yield* Effect.all([collect(handle.stdout), collect(handle.stderr)], { concurrency: 2 })
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
return {
exitCode: yield* handle.exitCode,
text: () => stdout.buffer.toString("utf8"),
stdout: stdout.buffer,
stderr: stderr.buffer,
truncated: stdout.truncated || stderr.truncated,
text: () => stdout,
stdout: Buffer.from(stdout),
stderr: Buffer.from(stderr),
} satisfies Result
},
Effect.scoped,
@@ -273,61 +240,6 @@ export const layer = Layer.effect(
})
})
const patch = Effect.fn("Git.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) {
const result = yield* run(
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
})
const patchAll = Effect.fn("Git.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) {
const result = yield* run(
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.text(), truncated: result.truncated } satisfies Patch
})
const patchUntracked = Effect.fn("Git.patchUntracked")(function* (
cwd: string,
file: string,
options?: PatchOptions,
) {
const result = yield* run(
[
"diff",
"--no-index",
"--patch",
"--no-ext-diff",
"--no-renames",
`--unified=${options?.context ?? 3}`,
"--",
"/dev/null",
file,
],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
})
const statUntracked = Effect.fn("Git.statUntracked")(function* (cwd: string, file: string) {
const result = yield* run(["diff", "--no-index", "--numstat", "--", "/dev/null", file], {
cwd,
maxOutputBytes: 4096,
})
if (result.truncated) return
const parts = result.text().split("\t")
if (parts.length < 2) return
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10)
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10)
return {
file,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
} satisfies Stat
})
return Service.of({
run,
branch,
@@ -339,10 +251,6 @@ export const layer = Layer.effect(
status,
diff,
stats,
patch,
patchAll,
patchUntracked,
statUntracked,
})
}),
)

View File

@@ -5,7 +5,8 @@ import { InstanceState } from "@/effect/instance-state"
import { ProjectID } from "@/project/schema"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database } from "@/storage/db"
import { DatabaseEffect } from "@/storage/db-effect"
import { getOne } from "@opencode-ai/effect-drizzle-sqlite"
import { eq } from "drizzle-orm"
import { zod } from "@/util/effect-zod"
import * as Log from "@opencode-ai/core/util/log"
@@ -153,11 +154,15 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const db = yield* DatabaseEffect.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Permission.state")(function* (ctx) {
const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
)
const row = yield* getOne(
db
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, ctx.project.id)),
).pipe(Effect.orDie)
const state = {
pending: new Map<PermissionID, PendingEntry>(),
approved: row?.data ?? [],
@@ -319,6 +324,6 @@ export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return result
}
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Bus.layer), Layer.provide(DatabaseEffect.layer))
export * as Permission from "."

View File

@@ -1,8 +1,10 @@
import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import * as Log from "@opencode-ai/core/util/log"
@@ -10,11 +12,20 @@ import { zod } from "@/util/effect-zod"
import { NonNegativeInt, withStatics } from "@/util/schema"
const log = Log.create({ service: "vcs" })
const PATCH_CONTEXT_LINES = 2_147_483_647
const MAX_PATCH_BYTES = 10_000_000
const MAX_TOTAL_PATCH_BYTES = 10_000_000
const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 }))
const count = (text: string) => {
if (!text) return 0
if (!text.endsWith("\n")) return text.split("\n").length
return text.slice(0, -1).split("\n").length
}
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
const full = path.join(cwd, file)
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
if (Buffer.from(buf).includes(0)) return ""
return Buffer.from(buf).toString("utf8")
})
const nums = (list: Git.Stat[]) =>
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
@@ -27,168 +38,59 @@ const merge = (...lists: Git.Item[][]) => {
return [...out.values()]
}
const emptyBatch = () => ({ patches: new Map<string, string>(), capped: false })
const parseQuotedPath = (value: string) => {
let out = ""
for (let idx = 1; idx < value.length; idx++) {
const char = value[idx]
if (char === '"') return { value: out, end: idx + 1 }
if (char !== "\\") {
out += char
continue
}
const next = value[++idx]
if (next === "t") out += "\t"
else if (next === "n") out += "\n"
else if (next === "r") out += "\r"
else if (next === '"' || next === "\\") out += next
else out += next ?? ""
}
}
const parsePathToken = (value: string) => {
if (!value.startsWith('"')) return value.split("\t")[0]
return parseQuotedPath(value)?.value ?? value
}
const fileFromDiffPath = (value: string | undefined) => {
if (!value || value === "/dev/null") return
const file = parsePathToken(value)
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
return file
}
const fileFromGitHeader = (header: string) => {
if (header.startsWith('"')) {
const first = parseQuotedPath(header)
const second = first ? header.slice(first.end).trimStart() : undefined
if (!second) return
if (!second.startsWith('"')) return fileFromDiffPath(second)
return fileFromDiffPath(parseQuotedPath(second)?.value)
}
const separator = header.indexOf(" b/")
if (separator === -1) return
return fileFromDiffPath(header.slice(separator + 1))
}
const fileFromPatchChunk = (chunk: string) => {
const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1]
const before = /^--- (.+)$/m.exec(chunk)?.[1]
const file = fileFromDiffPath(next) ?? fileFromDiffPath(before)
if (file) return file
const header = /^diff --git (.+)$/m.exec(chunk)?.[1]
return fileFromGitHeader(header ?? "")
}
const splitGitPatch = (patch: Git.Patch) => {
const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index)
const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length))
if (!patch.truncated) return chunks
return chunks.slice(0, -1)
}
const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) {
if (list.length === 0) return { patches: new Map<string, string>(), capped: false }
const result = yield* git.patchAll(cwd, ref, {
context: PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
})
if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES })
return {
patches: splitGitPatch(result).reduce((acc, patch, index) => {
const file = fileFromPatchChunk(patch) ?? list[index]?.file
if (!file) return acc
acc.set(file, (acc.get(file) ?? "") + patch)
return acc
}, new Map<string, string>()),
capped: result.truncated,
}
})
const nativePatch = Effect.fnUntraced(function* (
git: Git.Interface,
cwd: string,
ref: string | undefined,
item: Git.Item,
) {
const result =
item.code === "??" || !ref
? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
: yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
if (!result.truncated && result.text) return result.text
if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES })
return emptyPatch(item.file)
})
const totalPatch = (file: string, patch: string, total: number) => {
if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false }
log.warn("total patch budget exceeded", { file, max: MAX_TOTAL_PATCH_BYTES })
return { patch: emptyPatch(file), capped: true }
}
const patchForItem = Effect.fnUntraced(function* (
git: Git.Interface,
cwd: string,
ref: string | undefined,
item: Git.Item,
batch: { patches: Map<string, string>; capped: boolean },
capped: boolean,
) {
if (capped) return emptyPatch(item.file)
const batched = batch.patches.get(item.file)
if (batched !== undefined) return batched
if (item.code !== "??" && batch.capped) return emptyPatch(item.file)
return yield* nativePatch(git, cwd, ref, item)
})
const files = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string | undefined,
list: Git.Item[],
map: Map<string, { additions: number; deletions: number }>,
batch: { patches: Map<string, string>; capped: boolean },
) {
const next: FileDiff[] = []
let total = 0
let capped = false
for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) {
const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined)
const patch = yield* patchForItem(git, cwd, ref, item, batch, capped)
const result: { patch: string; capped: boolean } = capped
? { patch, capped: true }
: totalPatch(item.file, patch, total)
capped = capped || result.capped
if (!capped) {
total += Buffer.byteLength(result.patch)
capped = total >= MAX_TOTAL_PATCH_BYTES
}
next.push({
file: item.file,
patch: result.patch,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
status: item.status,
})
}
return next
const base = ref ? yield* git.prefix(cwd) : ""
const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
const next = yield* Effect.forEach(
list,
(item) =>
Effect.gen(function* () {
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
const stat = map.get(item.file)
return {
file: item.file,
patch: patch(item.file, before, after),
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
status: item.status,
} satisfies FileDiff
}),
{ concurrency: 8 },
)
return next.toSorted((a, b) => a.file.localeCompare(b.file))
})
const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) {
const track = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string | undefined,
) {
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
return yield* files(fs, git, cwd, ref, list, nums(stats))
})
const compare = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string,
) {
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
concurrency: 3,
})
return yield* files(
fs,
git,
cwd,
ref,
@@ -197,15 +99,9 @@ const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: str
extra.filter((item) => item.code === "??"),
),
nums(stats),
yield* batchPatches(git, cwd, ref, list),
)
})
const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) {
if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch())
return yield* diffAgainstRef(git, cwd, ref)
})
export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Mode = Schema.Schema.Type<typeof Mode>
@@ -251,9 +147,10 @@ interface State {
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Layer.effect(
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const bus = yield* Bus.Service
const scope = yield* Scope.Scope
@@ -307,19 +204,23 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return []
if (mode === "git") {
return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
return yield* track(fs, git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
}
if (!value.root) return []
if (value.current && value.current === value.root.name) return []
const ref = yield* git.mergeBase(ctx.directory, value.root.ref)
if (!ref) return []
return yield* diffAgainstRef(git, ctx.directory, ref)
return yield* compare(fs, git, ctx.directory, ref)
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(Bus.layer))
export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
)
export * as Vcs from "./vcs"

View File

@@ -1,8 +1,78 @@
import type { MiddlewareHandler } from "hono"
import { Database } from "@/storage/db"
import { inArray } from "drizzle-orm"
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 { HEADER, diff, load } from "./shared/fence"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
const log = Log.create({ service: "fence-middleware" })
const HEADER = "x-opencode-sync"
type State = Record<string, number>
const log = Log.create({ service: "fence" })
export function load(ids?: string[]) {
const rows = Database.use((db) => {
if (!ids?.length) {
return db.select().from(EventSequenceTable).all()
}
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
})
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State
}
export function diff(prev: State, next: State) {
const ids = new Set([...Object.keys(prev), ...Object.keys(next)])
return Object.fromEntries(
[...ids]
.map((id) => [id, next[id] ?? -1] as const)
.filter(([id, seq]) => {
return (prev[id] ?? -1) !== seq
}),
) as State
}
export function parse(headers: Headers) {
const raw = headers.get(HEADER)
if (!raw) return
let data
try {
data = JSON.parse(raw)
} catch {
return
}
if (!data || typeof data !== "object") return
return Object.fromEntries(
Object.entries(data).filter(([id, seq]) => {
return typeof id === "string" && Number.isInteger(seq)
}),
) 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) {
await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
}
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next()

View File

@@ -1,429 +0,0 @@
// TODO: Node adapter forthcoming — same pattern but using `node:http` + `ws` library,
// and `node:http`'s `upgrade` event.
//
// This module is a Bun-only proof-of-concept for a native `Bun.serve` listener that
// drives the experimental HttpApi handler directly (no Hono in the middle) and handles
// WebSocket upgrades inline based on path-matching. It exists to validate the pattern
// before deleting the Hono backend; `Server.listen()` is intentionally NOT wired to it.
import type { ServerWebSocket } from "bun"
import { Effect, Schema } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { AppRuntime } from "@/effect/app-runtime"
import { WithInstance } from "@/project/with-instance"
import { Pty } from "@/pty"
import { handlePtyInput } from "@/pty/input"
import { PtyID } from "@/pty/schema"
import { PtyPaths } from "@/server/routes/instance/httpapi/groups/pty"
import { ExperimentalHttpApiServer } from "@/server/routes/instance/httpapi/server"
import { getAdapter } from "@/control-plane/adapters"
import { WorkspaceID } from "@/control-plane/schema"
import { Workspace } from "@/control-plane/workspace"
import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import type { CorsOptions } from "./cors"
import { ProxyUtil } from "./proxy-util"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "./shared/workspace-routing"
const log = Log.create({ service: "httpapi-listener" })
const decodePtyID = Schema.decodeUnknownSync(PtyID)
export type Listener = {
hostname: string
port: number
url: URL
stop: (close?: boolean) => Promise<void>
}
export type ListenOptions = CorsOptions & {
port: number
hostname: string
}
type WsKind =
| { kind: "pty"; ptyID: string; cursor: number | undefined; directory: string }
| { kind: "proxy"; remoteURL: string; subprotocols: string[] }
type PtyHandler = {
onMessage: (message: string | ArrayBuffer) => void
onClose: () => void
}
type WsState = WsKind & {
// pty fields
handler?: PtyHandler
pending: Array<string | Uint8Array>
ready: boolean
closed: boolean
// proxy fields
remote?: WebSocket
proxyQueue?: Array<string | Uint8Array | ArrayBuffer>
}
// Derive from the OpenAPI path so this stays in sync if the route literal moves.
const ptyConnectPattern = new RegExp(`^${PtyPaths.connect.replace(/:[^/]+/g, "([^/]+)")}$`)
function parseCursor(value: string | null): number | undefined {
if (!value) return undefined
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < -1) return undefined
return parsed
}
function openProxy(ws: ServerWebSocket<WsState>) {
const data = ws.data
if (data.kind !== "proxy") return
let remote: WebSocket
try {
remote = new WebSocket(data.remoteURL, data.subprotocols.length ? data.subprotocols : undefined)
} catch (err) {
log.error("proxy remote WebSocket construct failed", { error: err })
ws.close(1011, "proxy connect failed")
return
}
remote.binaryType = "arraybuffer"
data.remote = remote
remote.onopen = () => {
const queue = data.proxyQueue
if (queue) {
for (const item of queue) {
try {
remote.send(item as never)
} catch {
// ignore — close handlers will clean up
}
}
queue.length = 0
}
}
remote.onmessage = (event: MessageEvent) => {
try {
const payload = event.data
if (typeof payload === "string") {
ws.send(payload)
} else if (payload instanceof ArrayBuffer) {
ws.send(new Uint8Array(payload))
} else if (payload instanceof Uint8Array) {
ws.send(payload)
} else if (payload instanceof Blob) {
void payload.arrayBuffer().then((buf) => {
try {
ws.send(new Uint8Array(buf))
} catch {
// ignore
}
})
}
} catch {
// ignore — socket likely closed
}
}
remote.onerror = () => {
try {
ws.close(1011, "proxy error")
} catch {
// ignore
}
}
remote.onclose = (event: CloseEvent) => {
try {
ws.close(event.code, event.reason)
} catch {
// ignore
}
}
}
function asAdapter(ws: ServerWebSocket<WsState>) {
return {
get readyState() {
return ws.readyState
},
send: (data: string | Uint8Array | ArrayBuffer) => {
try {
if (data instanceof ArrayBuffer) ws.send(new Uint8Array(data))
else ws.send(data)
} catch {
// socket likely already closed; ignore
}
},
close: (code?: number, reason?: string) => {
try {
ws.close(code, reason)
} catch {
// ignore
}
},
}
}
async function resolveWorkspaceProxy(
request: Request,
url: URL,
): Promise<{ remoteURL: URL; subprotocols: string[] } | undefined> {
// Skip proxy resolution entirely when this process is pinned to a single
// workspace (the Hono path's WorkspaceRouterMiddleware uses the same guard).
if (Flag.OPENCODE_WORKSPACE_ID) return undefined
// Local-only routes (e.g. /experimental/workspace, GET /session) never
// forward — match the Hono behavior even though those routes don't currently
// upgrade to WS.
if (isLocalWorkspaceRoute(request.method, url.pathname)) return undefined
// /console paths are served locally and never proxied.
if (url.pathname.startsWith("/console")) return undefined
let workspaceID: string | null = null
// Prefer session-derived workspace lookup when a session ID is present in
// the path; fall back to the explicit ?workspace=... query parameter.
const sessionID = getWorkspaceRouteSessionID(url)
if (sessionID) {
const session = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.withSpan("HttpApiListener.proxy.session")),
).catch(() => undefined)
if (session?.workspaceID) workspaceID = session.workspaceID
}
if (!workspaceID) workspaceID = url.searchParams.get("workspace")
if (!workspaceID) return undefined
const workspace = await AppRuntime.runPromise(
Workspace.Service.use((svc) => svc.get(WorkspaceID.make(workspaceID))).pipe(
Effect.withSpan("HttpApiListener.proxy.workspace"),
),
).catch(() => undefined)
if (!workspace) return undefined
const adapter = getAdapter(workspace.projectID, workspace.type)
const target = await adapter.target(workspace)
if (target.type !== "remote") return undefined
const proxyURL = workspaceProxyURL(target.url, url)
const remoteURL = new URL(ProxyUtil.websocketTargetURL(proxyURL))
return {
remoteURL,
subprotocols: ProxyUtil.websocketProtocols(request),
}
}
/**
* Spin up a native Bun.serve that:
* 1. Routes all HTTP traffic through the HttpApi web handler.
* 2. Intercepts known WebSocket upgrade paths and handles them inline.
*
* This bypasses Hono entirely. The Hono code path remains untouched.
*/
export async function listen(opts: ListenOptions): Promise<Listener> {
const built = ExperimentalHttpApiServer.webHandler(opts)
const handler = built.handler
const context = ExperimentalHttpApiServer.context
const start = (port: number) => {
try {
return Bun.serve<WsState>({
hostname: opts.hostname,
port,
idleTimeout: 0,
async fetch(request, server) {
const url = new URL(request.url)
const isUpgrade = request.headers.get("upgrade")?.toLowerCase() === "websocket"
const ptyMatch = url.pathname.match(ptyConnectPattern)
if (ptyMatch && isUpgrade) {
const ptyID = ptyMatch[1]!
const cursor = parseCursor(url.searchParams.get("cursor"))
// Resolve the instance directory the same way the HttpApi
// `instance-context` middleware does (search params, then header,
// then process.cwd()).
const directory =
url.searchParams.get("directory") ?? request.headers.get("x-opencode-directory") ?? process.cwd()
const upgraded = server.upgrade(request, {
data: {
kind: "pty",
ptyID,
cursor,
directory,
pending: [],
ready: false,
closed: false,
} satisfies WsState,
})
if (upgraded) return undefined
return new Response("upgrade failed", { status: 400 })
}
// Workspace-proxy WS forwarding. Mirrors the Hono path's
// `WorkspaceRouterMiddleware` → `ServerProxy.websocket` flow but inline.
// Bridging to the remote `new WebSocket(...)` happens inside the
// `websocket.open` handler below.
//
// TODO: Node adapter (no Bun.serve) needs an equivalent path using
// `node:http` + `ws`.
if (isUpgrade) {
try {
const proxy = await resolveWorkspaceProxy(request, url)
if (proxy) {
log.info("workspace-proxy websocket", {
request: url.toString(),
remote: proxy.remoteURL.toString(),
})
const upgraded = server.upgrade(request, {
data: {
kind: "proxy",
remoteURL: proxy.remoteURL.toString(),
subprotocols: proxy.subprotocols,
pending: [],
ready: false,
closed: false,
proxyQueue: [],
} satisfies WsState,
})
if (upgraded) return undefined
return new Response("upgrade failed", { status: 400 })
}
} catch (err) {
log.error("workspace-proxy ws resolve failed", { error: err })
return new Response("workspace lookup failed", { status: 500 })
}
}
return handler(request as Request, context as never)
},
websocket: {
open(ws) {
const data = ws.data
if (data.kind === "proxy") {
openProxy(ws)
return
}
if (data.kind !== "pty") {
ws.close(1011, "unknown ws kind")
return
}
const id = (() => {
try {
return decodePtyID(data.ptyID)
} catch {
ws.close(1008, "invalid pty id")
return undefined
}
})()
if (!id) return
;(async () => {
const result = await WithInstance.provide({
directory: data.directory,
fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.connect(id, asAdapter(ws), data.cursor)
}).pipe(Effect.withSpan("HttpApiListener.pty.connect.open")),
),
})
return await result
})()
.then((handler) => {
if (data.closed) {
handler?.onClose()
return
}
if (!handler) {
ws.close(4404, "session not found")
return
}
data.handler = handler
data.ready = true
for (const msg of data.pending) {
AppRuntime.runPromise(handlePtyInput(handler, msg)).catch(() => undefined)
}
data.pending.length = 0
})
.catch((err) => {
log.error("pty connect failed", { error: err })
ws.close(1011, "pty connect failed")
})
},
message(ws, message) {
const data = ws.data
if (data.kind === "proxy") {
const payload =
typeof message === "string"
? message
: message instanceof Buffer
? new Uint8Array(message.buffer, message.byteOffset, message.byteLength)
: (message as Uint8Array)
const remote = data.remote
if (remote && remote.readyState === WebSocket.OPEN) {
try {
remote.send(payload)
} catch {
// ignore send errors; lifecycle handlers will tear things down
}
return
}
data.proxyQueue?.push(payload)
return
}
if (data.kind !== "pty") return
const payload =
typeof message === "string"
? message
: message instanceof Buffer
? new Uint8Array(message.buffer, message.byteOffset, message.byteLength)
: (message as Uint8Array)
if (!data.ready || !data.handler) {
data.pending.push(payload)
return
}
AppRuntime.runPromise(handlePtyInput(data.handler, payload)).catch(() => undefined)
},
close(ws, code, reason) {
const data = ws.data
data.closed = true
if (data.kind === "proxy") {
try {
data.remote?.close(code, reason)
} catch {
// ignore
}
return
}
data.handler?.onClose()
},
},
})
} catch (err) {
log.error("Bun.serve failed", { error: err })
return undefined
}
}
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
const port = server.port
if (port === undefined) throw new Error("Bun.serve started without a numeric port")
const url = new URL("http://localhost")
url.hostname = opts.hostname
url.port = String(port)
let closing: Promise<void> | undefined
return {
hostname: opts.hostname,
port,
url,
stop(close?: boolean) {
closing ??= (async () => {
await server.stop(close)
// NOTE: we deliberately do NOT call `built.dispose()` here. The
// underlying `webHandler` is memoized at module level (same as the
// Hono path), so disposing it would tear down shared services for
// every other consumer in the process. Lifecycle teardown is owned
// by the AppRuntime itself.
})()
return closing
},
}
}
export * as HttpApiListener from "./httpapi-listener"

View File

@@ -1,7 +1,7 @@
import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import * as Log from "@opencode-ai/core/util/log"
import * as Fence from "./shared/fence"
import * as Fence from "./fence"
import type { WorkspaceID } from "@/control-plane/schema"
import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"

View File

@@ -5,7 +5,7 @@ import * as Database from "@/storage/db"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control"
import { nextTuiRequest, submitTuiResponse } from "../../tui"
import { InstanceHttpApi } from "../api"
import { CommandPayload, TuiPublishPayload } from "../groups/tui"

View File

@@ -5,8 +5,8 @@ import { Workspace } from "@/control-plane/workspace"
import { EffectBridge } from "@/effect/bridge"
import { Session } from "@/session/session"
import { HttpApiProxy } from "./proxy"
import * as Fence from "@/server/shared/fence"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing"
import * as Fence from "@/server/fence"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Data, Effect, Layer } from "effect"
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"

View File

@@ -45,7 +45,7 @@ import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Workspace } from "@/control-plane/workspace"
import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
import { serveUIEffect } from "@/server/shared/ui"
import { serveUIEffect } from "@/server/routes/ui"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
import { EventApi, eventHandlers } from "./event"

View File

@@ -7,16 +7,32 @@ import { Session } from "@/session/session"
import type { SessionID } from "@/session/schema"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { zodObject } from "@/util/effect-zod"
import { AsyncQueue } from "@/util/queue"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { runRequest } from "./trace"
import {
TuiRequest,
nextTuiRequest,
nextTuiResponse,
submitTuiRequest,
submitTuiResponse,
} from "@/server/shared/tui-control"
export const TuiRequest = z.object({
path: z.string(),
body: z.any(),
})
export type TuiRequest = z.infer<typeof TuiRequest>
const request = new AsyncQueue<TuiRequest>()
const response = new AsyncQueue<unknown>()
export function nextTuiRequest() {
return request.next()
}
export function submitTuiRequest(body: TuiRequest) {
request.push(body)
}
export function submitTuiResponse(body: unknown) {
response.push(body)
}
export async function callTui(ctx: Context) {
const body = await ctx.req.json()
@@ -24,7 +40,7 @@ export async function callTui(ctx: Context) {
path: ctx.req.path,
body,
})
return nextTuiResponse()
return response.next()
}
const TuiControlRoutes = new Hono()

View File

@@ -1,10 +1,53 @@
import fs from "node:fs/promises"
import { createHash } from "node:crypto"
import { Flag } from "@opencode-ai/core/flag/flag"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Stream } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { Hono } from "hono"
import { proxy } from "hono/proxy"
import { getMimeType } from "hono/utils/mime"
import { createHash } from "node:crypto"
import fs from "node:fs/promises"
import { ProxyUtil } from "../proxy-util"
import { DEFAULT_CSP, UI_UPSTREAM, csp, embeddedUI, themePreloadHash, upstreamURL } from "../shared/ui"
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
? Promise.resolve(null)
: // @ts-expect-error - generated file at build time
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
const DEFAULT_CSP =
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
const UI_UPSTREAM = new URL("https://app.opencode.ai")
const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
function themePreloadHash(body: string) {
return body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
}
function requestBody(request: HttpServerRequest.HttpServerRequest) {
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
const len = request.headers["content-length"]
return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len))
}
function proxyResponseHeaders(headers: Record<string, string>) {
const result = new Headers(headers)
// FetchHttpClient exposes decoded response bodies, so forwarding upstream
// transfer metadata makes browsers decode already-decoded assets again.
result.delete("content-encoding")
result.delete("content-length")
return result
}
function upstreamURL(path: string) {
return new URL(path, UI_UPSTREAM).toString()
}
function embeddedUI() {
if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null)
return embeddedUIPromise
}
export async function serveUI(request: Request) {
const embeddedWebUI = await embeddedUI()
@@ -15,7 +58,7 @@ export async function serveUI(request: Request) {
if (!match) return Response.json({ error: "Not Found" }, { status: 404 })
if (await fs.exists(match)) {
const mime = AppFileSystem.mimeType(match)
const mime = getMimeType(match) ?? "text/plain"
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
return new Response(new Uint8Array(await fs.readFile(match)), { headers })
@@ -36,4 +79,49 @@ export async function serveUI(request: Request) {
return response
}
export function serveUIEffect(
request: HttpServerRequest.HttpServerRequest,
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient },
) {
return Effect.gen(function* () {
const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
const path = new URL(request.url, "http://localhost").pathname
if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
if (yield* services.fs.existsSafe(match)) {
const mime = getMimeType(match) ?? "text/plain"
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers })
}
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
}
const response = yield* services.client.execute(
HttpClientRequest.make(request.method)(upstreamURL(path), {
headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }),
body: requestBody(request),
}),
)
const headers = proxyResponseHeaders(response.headers)
if (response.headers["content-type"]?.includes("text/html")) {
const body = yield* response.text
const match = themePreloadHash(body)
headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : ""))
return HttpServerResponse.text(body, { status: response.status, headers })
}
headers.set("Content-Security-Policy", csp())
return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
status: response.status,
headers,
})
})
}
export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw))

View File

@@ -5,7 +5,6 @@ import { lazy } from "@/util/lazy"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { WorkspaceID } from "@/control-plane/schema"
import { OpenApi } from "effect/unstable/httpapi"
import { MDNS } from "./mdns"
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
import { FenceMiddleware } from "./fence"
@@ -18,7 +17,6 @@ import { WorkspaceRouterMiddleware } from "./workspace"
import { InstanceMiddleware } from "./routes/instance/middleware"
import { WorkspaceRoutes } from "./routes/control/workspace"
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
import { PublicApi } from "./routes/instance/httpapi/public"
import * as ServerBackend from "./backend"
import type { CorsOptions } from "./cors"
@@ -137,30 +135,7 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv
}
}
/**
* Generate the OpenAPI document used by the SDK build.
*
* Since the Effect HttpApi backend now covers every Hono route (plus the new
* `/api/session/*` v2 routes — see `httpapi-bridge.test.ts` for the parity
* audit), `Server.openapi()` derives the spec from `OpenApi.fromApi(PublicApi)`.
* `PublicApi` is `OpenCodeHttpApi` annotated with the `matchLegacyOpenApi`
* transform that injects instance query parameters, strips Effect's optional
* null arms, normalizes component names, and patches SSE response schemas so
* the generated SDK keeps the legacy Hono shape.
*
* The Hono-derived spec is still reachable via `openapiHono()` so reviewers
* can diff the two outputs while the Hono backend lingers; once the Hono
* backend is deleted that helper goes with it.
*/
export async function openapi() {
return OpenApi.fromApi(PublicApi)
}
/**
* Hono-derived OpenAPI spec, retained for parity diffing only. Delete once
* the Hono backend is removed.
*/
export async function openapiHono() {
// Build a fresh app with all routes registered directly so
// hono-openapi can see describeRoute metadata (`.route()` wraps
// handlers when the sub-app has a custom errorHandler, which

View File

@@ -1,74 +0,0 @@
import { Database } from "@/storage/db"
import { inArray } from "drizzle-orm"
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"
export const HEADER = "x-opencode-sync"
export type State = Record<string, number>
const log = Log.create({ service: "fence" })
export function load(ids?: string[]) {
const rows = Database.use((db) => {
if (!ids?.length) {
return db.select().from(EventSequenceTable).all()
}
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
})
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State
}
export function diff(prev: State, next: State) {
const ids = new Set([...Object.keys(prev), ...Object.keys(next)])
return Object.fromEntries(
[...ids]
.map((id) => [id, next[id] ?? -1] as const)
.filter(([id, seq]) => {
return (prev[id] ?? -1) !== seq
}),
) as State
}
export function parse(headers: Headers) {
const raw = headers.get(HEADER)
if (!raw) return
let data
try {
data = JSON.parse(raw)
} catch {
return
}
if (!data || typeof data !== "object") return
return Object.fromEntries(
Object.entries(data).filter(([id, seq]) => {
return typeof id === "string" && Number.isInteger(seq)
}),
) 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) {
await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
}

View File

@@ -1,28 +0,0 @@
import z from "zod"
import { AsyncQueue } from "@/util/queue"
export const TuiRequest = z.object({
path: z.string(),
body: z.any(),
})
export type TuiRequest = z.infer<typeof TuiRequest>
const request = new AsyncQueue<TuiRequest>()
const response = new AsyncQueue<unknown>()
export function nextTuiRequest() {
return request.next()
}
export function submitTuiRequest(body: TuiRequest) {
request.push(body)
}
export function submitTuiResponse(body: unknown) {
response.push(body)
}
export function nextTuiResponse() {
return response.next()
}

View File

@@ -1,91 +0,0 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Stream } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createHash } from "node:crypto"
import { ProxyUtil } from "../proxy-util"
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
? Promise.resolve(null)
: // @ts-expect-error - generated file at build time
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
export const DEFAULT_CSP =
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
export const UI_UPSTREAM = new URL("https://app.opencode.ai")
export const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
export function themePreloadHash(body: string) {
return body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
}
function requestBody(request: HttpServerRequest.HttpServerRequest) {
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
const len = request.headers["content-length"]
return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len))
}
function proxyResponseHeaders(headers: Record<string, string>) {
const result = new Headers(headers)
// FetchHttpClient exposes decoded response bodies, so forwarding upstream
// transfer metadata makes browsers decode already-decoded assets again.
result.delete("content-encoding")
result.delete("content-length")
return result
}
export function upstreamURL(path: string) {
return new URL(path, UI_UPSTREAM).toString()
}
export function embeddedUI() {
if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null)
return embeddedUIPromise
}
export function serveUIEffect(
request: HttpServerRequest.HttpServerRequest,
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient },
) {
return Effect.gen(function* () {
const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
const path = new URL(request.url, "http://localhost").pathname
if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
if (yield* services.fs.existsSafe(match)) {
const mime = AppFileSystem.mimeType(match)
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers })
}
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
}
const response = yield* services.client.execute(
HttpClientRequest.make(request.method)(upstreamURL(path), {
headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }),
body: requestBody(request),
}),
)
const headers = proxyResponseHeaders(response.headers)
if (response.headers["content-type"]?.includes("text/html")) {
const body = yield* response.text
const match = themePreloadHash(body)
headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : ""))
return HttpServerResponse.text(body, { status: response.status, headers })
}
headers.set("Content-Security-Policy", csp())
return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
status: response.status,
headers,
})
})
}

View File

@@ -1,36 +0,0 @@
import { SessionID } from "@/session/schema"
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" },
]
export function isLocalWorkspaceRoute(method: string, path: string) {
for (const rule of RULES) {
if (rule.method && rule.method !== method) continue
const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
if (match) return rule.action === "local"
}
return false
}
export function getWorkspaceRouteSessionID(url: URL) {
if (url.pathname === "/session/status") return null
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
if (!id) return null
return SessionID.make(id)
}
export function workspaceProxyURL(target: string | URL, requestURL: URL) {
const proxyURL = new URL(target)
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}`
proxyURL.search = requestURL.search
proxyURL.hash = requestURL.hash
proxyURL.searchParams.delete("workspace")
return proxyURL
}

View File

@@ -8,10 +8,45 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { AppRuntime } from "@/effect/app-runtime"
import { WithInstance } from "@/project/with-instance"
import { Session } from "@/session/session"
import { SessionID } from "@/session/schema"
import { Effect } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import { ServerProxy } from "./proxy"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "./shared/workspace-routing"
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" },
]
export function isLocalWorkspaceRoute(method: string, path: string) {
for (const rule of RULES) {
if (rule.method && rule.method !== method) continue
const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
if (match) return rule.action === "local"
}
return false
}
export function getWorkspaceRouteSessionID(url: URL) {
if (url.pathname === "/session/status") return null
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
if (!id) return null
return SessionID.make(id)
}
export function workspaceProxyURL(target: string | URL, requestURL: URL) {
const proxyURL = new URL(target)
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}`
proxyURL.search = requestURL.search
proxyURL.hash = requestURL.hash
proxyURL.searchParams.delete("workspace")
return proxyURL
}
async function getSessionWorkspace(url: URL) {
const id = getWorkspaceRouteSessionID(url)

View File

@@ -5,7 +5,7 @@ import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { Effect, Layer, Context, Schema } from "effect"
import z from "zod"
import { Database } from "@/storage/db"
import { DatabaseEffect } from "@/storage/db-effect"
import { eq } from "drizzle-orm"
import { asc } from "drizzle-orm"
import { TodoTable } from "./session.sql"
@@ -42,34 +42,34 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const db = yield* DatabaseEffect.Service
const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) {
yield* Effect.sync(() =>
Database.transaction((db) => {
db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
if (input.todos.length === 0) return
db.insert(TodoTable)
.values(
input.todos.map((todo, position) => ({
session_id: input.sessionID,
content: todo.content,
status: todo.status,
priority: todo.priority,
position,
})),
)
.run()
}),
)
yield* Effect.gen(function* () {
yield* db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID))
if (input.todos.length === 0) return
yield* db.insert(TodoTable).values(
input.todos.map((todo, position) => ({
session_id: input.sessionID,
content: todo.content,
status: todo.status,
priority: todo.priority,
position,
})),
)
}).pipe(db.withTransaction, Effect.orDie)
yield* bus.publish(Event.Updated, input)
})
const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) {
const rows = yield* Effect.sync(() =>
Database.use((db) =>
db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(),
),
)
const rows = yield* db
.select()
.from(TodoTable)
.where(eq(TodoTable.session_id, sessionID))
.orderBy(asc(TodoTable.position))
.pipe(Effect.orDie)
return rows.map((row) => ({
content: row.content,
status: row.status,
@@ -81,6 +81,6 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Bus.layer), Layer.provide(DatabaseEffect.layer))
export * as Todo from "./todo"

View File

@@ -9,7 +9,8 @@ import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2"
import type { SessionID } from "@/session/schema"
import { Database } from "@/storage/db"
import { DatabaseEffect } from "@/storage/db-effect"
import { getOne } from "@opencode-ai/effect-drizzle-sqlite"
import { eq } from "drizzle-orm"
import { Config } from "@/config/config"
import * as Log from "@opencode-ai/core/util/log"
@@ -76,9 +77,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ShareNext") {}
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
function api(resource: string): Api {
return {
create: `/api/${resource}`,
@@ -116,6 +114,7 @@ export const layer = Layer.effect(
const httpOk = HttpClient.filterStatusOk(http)
const provider = yield* Provider.Service
const session = yield* Session.Service
const db = yield* DatabaseEffect.Service
function sync(sessionID: SessionID, data: Data[]): Effect.Effect<void> {
return Effect.gen(function* () {
@@ -226,9 +225,9 @@ export const layer = Layer.effect(
})
const get = Effect.fnUntraced(function* (sessionID: SessionID) {
const row = yield* db((db) =>
db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(),
)
const row = yield* getOne(
db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)),
).pipe(Effect.orDie)
if (!row) return
return { id: row.id, secret: row.secret, url: row.url } satisfies Share
})
@@ -314,16 +313,13 @@ export const layer = Layer.effect(
Effect.flatMap((r) => httpOk.execute(r)),
Effect.flatMap(HttpClientResponse.schemaBodyJson(ShareSchema)),
)
yield* db((db) =>
db
.insert(SessionShareTable)
.values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url })
.onConflictDoUpdate({
target: SessionShareTable.session_id,
set: { id: result.id, secret: result.secret, url: result.url },
})
.run(),
)
yield* db
.insert(SessionShareTable)
.values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url })
.onConflictDoUpdate({
target: SessionShareTable.session_id,
set: { id: result.id, secret: result.secret, url: result.url },
})
const s = yield* InstanceState.get(state)
s.shared.set(sessionID, result)
yield* full(sessionID).pipe(
@@ -355,7 +351,7 @@ export const layer = Layer.effect(
Effect.flatMap((r) => httpOk.execute(r)),
)
yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
yield* db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID))
s.shared.delete(sessionID)
s.queue.delete(sessionID)
})
@@ -364,13 +360,14 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Account.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(DatabaseEffect.layer),
)
export * as ShareNext from "./share-next"

View File

@@ -0,0 +1,20 @@
import { Database } from "@/storage/db"
import { Context, Layer } from "effect"
import type { EffectSQLiteDatabase } from "@opencode-ai/effect-drizzle-sqlite"
import * as StorageSchema from "@/storage/schema"
export class Service extends Context.Service<Service, EffectSQLiteDatabase<typeof StorageSchema>>()(
"@opencode/DatabaseEffect",
) {}
const client = new Proxy({} as EffectSQLiteDatabase<typeof StorageSchema>, {
get(_target, property) {
const db = Database.Client()
const value = Reflect.get(db, property)
return typeof value === "function" ? value.bind(db) : value
},
})
export const layer = Layer.succeed(Service, client)
export * as DatabaseEffect from "./db-effect"

View File

@@ -1,8 +1,6 @@
import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { drizzle } from "@opencode-ai/effect-drizzle-sqlite"
export function init(path: string) {
const sqlite = new Database(path, { create: true })
const db = drizzle({ client: sqlite })
return db
export function init<TSchema extends Record<string, unknown>>(path: string, schema: TSchema) {
return drizzle({ client: new Database(path, { create: true }), schema })
}

View File

@@ -1,8 +1,6 @@
import { DatabaseSync } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite"
export function init(path: string) {
const sqlite = new DatabaseSync(path)
const db = drizzle({ client: sqlite })
return db
export function init<TSchema extends Record<string, unknown>>(path: string, schema: TSchema) {
return drizzle({ client: new DatabaseSync(path), schema })
}

View File

@@ -1,6 +1,4 @@
import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
export * from "drizzle-orm"
import { LocalContext } from "@/util/local-context"
import { lazy } from "../util/lazy"
@@ -14,6 +12,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
import { InstanceState } from "@/effect/instance-state"
import { iife } from "@/util/iife"
import * as StorageSchema from "@/storage/schema"
import { init } from "#db"
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
@@ -42,16 +41,16 @@ export const Path = iife(() => {
return getChannelPath()
})
export type Transaction = SQLiteTransaction<"sync", void>
export type Client = ReturnType<typeof open>
type Client = SQLiteBunDatabase
export type Transaction = Parameters<Parameters<Client["transaction"]>[0]>[0]
type Journal = { sql: string; timestamp: number; name: string }[]
// Drizzle's migrate overloads trigger expensive variance checks here; narrow to the journal overload we actually use.
const migrateFromJournal = migrate as unknown as (db: SQLiteBunDatabase, entries: Journal) => void
const migrateFromJournal = migrate as unknown as (db: Client, entries: Journal) => void
function applyMigrations(db: SQLiteBunDatabase, entries: Journal) {
function applyMigrations(db: Client, entries: Journal) {
migrateFromJournal(db, entries)
}
@@ -88,10 +87,10 @@ function migrations(dir: string): Journal {
return sql.sort((a, b) => a.timestamp - b.timestamp)
}
export const Client = lazy(() => {
export function open() {
log.info("opening database", { path: Path })
const db = init(Path)
const db = init(Path, StorageSchema)
db.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA synchronous = NORMAL")
@@ -119,7 +118,9 @@ export const Client = lazy(() => {
}
return db
})
}
export const Client = lazy(open)
export function close() {
if (!Client.loaded()) return
@@ -140,7 +141,8 @@ export function use<T>(callback: (trx: TxOrDb) => T): T {
} catch (err) {
if (err instanceof LocalContext.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const result = ctx.provide({ effects, tx: Client() }, () => callback(Client()))
const client = Client()
const result = ctx.provide({ effects, tx: client }, () => callback(client))
for (const effect of effects) effect()
return result
}

View File

@@ -15,6 +15,6 @@ export function lazy<T>(fn: () => T) {
}
result.loaded = () => loaded
result.peek = () => (loaded ? value : undefined)
return result
}

View File

@@ -11,7 +11,6 @@ import { ProjectID } from "@/project/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { SessionEvent } from "./session-event"
import { V2Schema } from "./schema"
import { optionalOmitUndefined } from "@/util/schema"
export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({
identifier: "Session.Delivery",
@@ -22,20 +21,20 @@ export const DefaultDelivery = "immediate" satisfies Delivery
export class Info extends Schema.Class<Info>("Session.Info")({
id: SessionID,
parentID: optionalOmitUndefined(SessionID),
parentID: SessionID.pipe(Schema.optional),
projectID: ProjectID,
workspaceID: optionalOmitUndefined(WorkspaceID),
path: optionalOmitUndefined(Schema.String),
agent: optionalOmitUndefined(Schema.String),
workspaceID: WorkspaceID.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: Schema.Struct({
id: ModelID,
providerID: ProviderID,
variant: optionalOmitUndefined(Schema.String),
}).pipe(optionalOmitUndefined),
variant: Schema.String.pipe(Schema.optional),
}).pipe(Schema.optional),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
updated: V2Schema.DateTimeUtcFromMillis,
archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis),
archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
}),
title: Schema.String,
/*
@@ -110,7 +109,7 @@ export const layer = Layer.effect(
decodeMessage({ ...row.data, id: row.id, type: row.type })
function fromRow(row: typeof SessionTable.$inferSelect): Info {
return new Info({
return {
id: SessionID.make(row.id),
projectID: ProjectID.make(row.project_id),
workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined,
@@ -130,7 +129,7 @@ export const layer = Layer.effect(
updated: DateTime.makeUnsafe(row.time_updated),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
})
}
}
const result: Interface = {

View File

@@ -1,48 +0,0 @@
import { afterEach, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture"
afterEach(async () => {
await disposeAllInstances()
})
// Regression for PR #25522: when an effectCmd handler does
// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`,
// the inner runPromise creates a fresh fiber after `await` whose Effect context
// has lost the outer InstanceRef. Services that read `InstanceState.context`
// then fall back to `Instance.current` ALS, which must be installed at the JS
// callback boundary (Node ALS persists across awaits, Effect's fiber context
// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap.
// Pins effect-cmd.ts directly: the pattern test below exercises the load +
// Instance.restore + dispose triple via the shared `provideTestInstance` fixture,
// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't
// fail it. This grep guards the actual production callsite.
test("effect-cmd.ts wraps the handler body in Instance.restore", async () => {
const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8")
expect(source).toContain("Instance.restore(ctx")
})
test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => {
await using dir = await tmpdir({ git: true })
await provideTestInstance({
directory: dir.path,
fn: () =>
Effect.runPromise(
Effect.promise(async () => {
await new Promise((r) => setTimeout(r, 5))
const current = await Effect.runPromise(
Effect.sync(() => {
try {
return Instance.current
} catch {
return undefined
}
}),
)
expect(current?.directory).toBe(dir.path)
}),
),
})
})

View File

@@ -114,53 +114,6 @@ describe("Git", () => {
})
})
test("patch() returns capped native patch output", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")
await fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")
await fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8")
await withGit(async (rt) => {
const [patch, all, capped] = await Promise.all([
rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }))),
rt.runPromise(Git.Service.use((git) => git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }))),
rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }))),
])
expect(patch.truncated).toBe(false)
expect(patch.text).toContain("diff --git")
expect(patch.text).toContain("-before")
expect(patch.text).toContain("+after")
expect(all.truncated).toBe(false)
expect(all.text).toContain("diff --git")
expect(all.text).toContain("other.txt")
expect(all.text).toContain("+new")
expect(capped.truncated).toBe(true)
expect(capped.text).toBe("")
})
})
test("patchUntracked() and statUntracked() handle added files", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8")
await withGit(async (rt) => {
const [patch, stat] = await Promise.all([
rt.runPromise(Git.Service.use((git) => git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }))),
rt.runPromise(Git.Service.use((git) => git.statUntracked(tmp.path, weird))),
])
expect(patch.truncated).toBe(false)
expect(patch.text).toContain("diff --git")
expect(patch.text).toContain("+one")
expect(patch.text).toContain("+two")
expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 }))
})
})
test("show() returns empty text for binary blobs", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3]))

View File

@@ -8,6 +8,7 @@ import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { DatabaseEffect } from "../../src/storage/db-effect"
import {
disposeAllInstances,
provideInstance,
@@ -19,7 +20,11 @@ import { testEffect } from "../lib/effect"
import { MessageID, SessionID } from "../../src/session/schema"
const bus = Bus.layer
const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer)
const env = Layer.mergeAll(
Permission.layer.pipe(Layer.provide(bus), Layer.provide(DatabaseEffect.layer)),
bus,
CrossSpawnSpawner.defaultLayer,
)
const it = testEffect(env)
afterEach(async () => {

View File

@@ -234,7 +234,6 @@ describe("Vcs diff", () => {
}),
]),
)
expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git")
})
})
@@ -260,34 +259,6 @@ describe("Vcs diff", () => {
})
})
test("diff('git') keeps batched patches aligned for type changes", async () => {
if (process.platform === "win32") return
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "a.txt"), "old\n", "utf-8")
await fs.writeFile(path.join(tmp.path, "b.txt"), "old\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add files"`.cwd(tmp.path).quiet()
await fs.unlink(path.join(tmp.path, "a.txt"))
await fs.symlink("target", path.join(tmp.path, "a.txt"))
await fs.writeFile(path.join(tmp.path, "b.txt"), "new\n", "utf-8")
await withVcsOnly(tmp.path, async () => {
const diff = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff("git")
}),
)
const a = diff.find((item) => item.file === "a.txt")
const b = diff.find((item) => item.file === "b.txt")
expect(a?.patch).toContain("deleted file mode")
expect(a?.patch).toContain("new file mode")
expect(b?.patch).toContain("+new")
})
})
test("diff('branch') returns changes against default branch", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M main`.cwd(tmp.path).quiet()

View File

@@ -222,7 +222,7 @@ describe("HttpApi server", () => {
})
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
const honoRoutes = openApiRouteKeys(await Server.openapiHono())
const honoRoutes = openApiRouteKeys(await Server.openapi())
const effectRoutes = openApiRouteKeys(effectOpenApi())
expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
@@ -237,7 +237,7 @@ describe("HttpApi server", () => {
})
test("matches generated OpenAPI route parameters", async () => {
const hono = openApiParameters(await Server.openapiHono())
const hono = openApiParameters(await Server.openapi())
const effect = openApiParameters(effectOpenApi())
expect(
@@ -248,7 +248,7 @@ describe("HttpApi server", () => {
})
test("matches generated OpenAPI request body shape", async () => {
const hono = openApiRequestBodies(await Server.openapiHono())
const hono = openApiRequestBodies(await Server.openapi())
const effect = openApiRequestBodies(effectOpenApi())
expect(

View File

@@ -1,211 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { ServerWebSocket } from "bun"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Project } from "../../src/project/project"
import { HttpApiListener } from "../../src/server/httpapi-listener"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { Effect } from "effect"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const testPty = process.platform === "win32" ? test.skip : test
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
async function startListener() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return HttpApiListener.listen({ hostname: "127.0.0.1", port: 0 })
}
describe("native HttpApi listener", () => {
test("serves HTTP routes via the HttpApi web handler", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const response = await fetch(`${listener.url.origin}${PtyPaths.shells}`, {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
const body = await response.json()
expect(Array.isArray(body)).toBe(true)
expect(body[0]).toMatchObject({
path: expect.any(String),
name: expect.any(String),
acceptable: expect.any(Boolean),
})
} finally {
await listener.stop(true)
}
})
test("workspace-proxy WS forwarding round-trips through a fake remote", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
// Tiny Bun.serve fake remote that echoes every WS frame it receives.
type EchoState = { closed: boolean }
const remote = Bun.serve<EchoState>({
hostname: "127.0.0.1",
port: 0,
fetch(request, server) {
if (request.headers.get("upgrade")?.toLowerCase() === "websocket") {
if (server.upgrade(request, { data: { closed: false } })) return undefined
return new Response("upgrade failed", { status: 400 })
}
return new Response("ok")
},
websocket: {
open(_ws: ServerWebSocket<EchoState>) {},
message(ws: ServerWebSocket<EchoState>, msg: string | Buffer) {
ws.send(typeof msg === "string" ? `echo:${msg}` : msg)
},
close(_ws: ServerWebSocket<EchoState>) {},
},
})
// The path "/probe" is not a known local-only or PTY route, so the listener
// should treat it as a candidate for workspace-proxy WS forwarding.
const remoteBase = `http://${remote.hostname}:${remote.port}`
// Register a remote workspace whose target points at the echo server.
const adapter: WorkspaceAdapter = {
name: "Remote Listener Test",
description: "Remote workspace target for HttpApiListener proxy WS test",
configure: (info) => ({ ...info, name: "remote-listener-test", directory: path.join(tmp.path, ".remote") }),
create: async () => {
await mkdir(path.join(tmp.path, ".remote"), { recursive: true })
},
async remove() {},
target: () => ({ type: "remote" as const, url: remoteBase }),
}
const workspaceID = await AppRuntime.runPromise(
Effect.gen(function* () {
const project = yield* Project.Service.use((svc) => svc.fromDirectory(tmp.path))
registerAdapter(project.project.id, "httpapi-listener-proxy-ws", adapter)
const created = yield* Workspace.Service.use((svc) =>
svc.create({
type: "httpapi-listener-proxy-ws",
branch: null,
extra: null,
projectID: project.project.id,
}),
)
return created.id
}),
)
const listener = await startListener()
try {
const wsURL = new URL("/probe", listener.url)
wsURL.protocol = "ws:"
wsURL.searchParams.set("workspace", workspaceID)
const messages: string[] = []
const ws = new WebSocket(wsURL)
ws.binaryType = "arraybuffer"
const opened = new Promise<void>((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true })
ws.addEventListener("error", () => reject(new Error("ws error before open")), { once: true })
})
ws.addEventListener("message", (event) => {
const data = event.data
messages.push(typeof data === "string" ? data : new TextDecoder().decode(data as ArrayBuffer))
})
await opened
ws.send("hello-proxy")
const start = Date.now()
while (!messages.some((m) => m === "echo:hello-proxy") && Date.now() - start < 5_000) {
await new Promise((r) => setTimeout(r, 25))
}
expect(messages).toContain("echo:hello-proxy")
ws.close(1000, "done")
} finally {
await listener.stop(true)
remote.stop(true)
}
})
testPty("PTY websocket connect echoes input back to the client", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const created = await fetch(`${listener.url.origin}${PtyPaths.create}`, {
method: "POST",
headers: {
"x-opencode-directory": tmp.path,
"content-type": "application/json",
},
body: JSON.stringify({ command: "/bin/cat", title: "listener-smoke" }),
})
expect(created.status).toBe(200)
const info = (await created.json()) as { id: string }
try {
const wsURL = new URL(PtyPaths.connect.replace(":ptyID", info.id), listener.url)
wsURL.protocol = "ws:"
wsURL.searchParams.set("directory", tmp.path)
wsURL.searchParams.set("cursor", "-1")
const messages: string[] = []
const ws = new WebSocket(wsURL)
ws.binaryType = "arraybuffer"
const opened = new Promise<void>((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true })
ws.addEventListener("error", () => reject(new Error("ws error before open")), { once: true })
})
const closed = new Promise<void>((resolve) => {
ws.addEventListener("close", () => resolve(), { once: true })
})
ws.addEventListener("message", (event) => {
const data = event.data
messages.push(typeof data === "string" ? data : new TextDecoder().decode(data as ArrayBuffer))
})
await opened
ws.send("ping-listener\n")
const start = Date.now()
while (!messages.some((m) => m.includes("ping-listener")) && Date.now() - start < 5_000) {
await new Promise((r) => setTimeout(r, 50))
}
ws.close(1000, "done")
expect(messages.some((m) => m.includes("ping-listener"))).toBe(true)
// Verify close event fires (handler.onClose path runs and the
// Bun.serve websocket lifecycle reaches close).
await closed
expect(ws.readyState).toBe(WebSocket.CLOSED)
} finally {
await fetch(`${listener.url.origin}${PtyPaths.remove.replace(":ptyID", info.id)}`, {
method: "DELETE",
headers: { "x-opencode-directory": tmp.path },
}).catch(() => undefined)
}
} finally {
await listener.stop(true)
}
})
})

View File

@@ -46,7 +46,7 @@ afterEach(async () => {
describe("tui HttpApi bridge", () => {
test("documents legacy bad request responses", async () => {
const legacy = await Server.openapiHono()
const legacy = await Server.openapi()
const effect = OpenApi.fromApi(TuiApi)
for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) {
expect(legacy.paths[path].post?.responses?.[400]).toBeDefined()

View File

@@ -17,7 +17,7 @@ import {
authorizationRouterMiddleware,
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { serveUIEffect } from "../../src/server/shared/ui"
import { serveUIEffect } from "../../src/server/routes/ui"
import { Server } from "../../src/server/server"
void Log.init({ print: false })

View File

@@ -1,9 +1,5 @@
import { describe, expect, test } from "bun:test"
import {
isLocalWorkspaceRoute,
getWorkspaceRouteSessionID,
workspaceProxyURL,
} from "../../src/server/shared/workspace-routing"
import { isLocalWorkspaceRoute, getWorkspaceRouteSessionID, workspaceProxyURL } from "../../src/server/workspace"
import { SessionID } from "../../src/session/schema"
describe("isLocalWorkspaceRoute", () => {

View File

@@ -39,6 +39,7 @@ import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { DatabaseEffect } from "@/storage/db-effect"
import * as Log from "@opencode-ai/core/util/log"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import * as Database from "../../src/storage/db"
@@ -170,6 +171,7 @@ function makeHttp() {
lsp,
mcp,
AppFileSystem.defaultLayer,
DatabaseEffect.layer,
status,
).pipe(Layer.provideMerge(infra))
const question = Question.layer.pipe(Layer.provideMerge(deps))

View File

@@ -51,6 +51,7 @@ import { SessionStatus } from "../../src/session/status"
import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { DatabaseEffect } from "@/storage/db-effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
@@ -120,6 +121,7 @@ function makeHttp() {
lsp,
mcp,
AppFileSystem.defaultLayer,
DatabaseEffect.layer,
status,
).pipe(Layer.provideMerge(infra))
const question = Question.layer.pipe(Layer.provideMerge(deps))

View File

@@ -15,6 +15,7 @@ import type { SessionID } from "../../src/session/schema"
import { ShareNext } from "@/share/share-next"
import { SessionShareTable } from "../../src/share/share.sql"
import { Database } from "@/storage/db"
import { DatabaseEffect } from "@/storage/db-effect"
import { eq } from "drizzle-orm"
import { provideTmpdirInstance } from "../fixture/fixture"
import { resetDatabase } from "../fixture/db"
@@ -39,18 +40,6 @@ const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unkno
const none = HttpClient.make(() => Effect.die("unexpected http call"))
function live(client: HttpClient.HttpClient) {
const http = Layer.succeed(HttpClient.HttpClient, client)
return ShareNext.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(http))),
Layer.provide(Config.defaultLayer),
Layer.provide(http),
Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer),
)
}
function wired(client: HttpClient.HttpClient) {
const http = Layer.succeed(HttpClient.HttpClient, client)
return Layer.mergeAll(
@@ -66,6 +55,7 @@ function wired(client: HttpClient.HttpClient) {
Layer.provide(Config.defaultLayer),
Layer.provide(http),
Layer.provide(Provider.defaultLayer),
Layer.provide(DatabaseEffect.layer),
)
}
@@ -104,7 +94,7 @@ describe("ShareNext", () => {
expect(req.baseUrl).toBe("https://legacy-share.example.com")
expect(req.headers).toEqual({})
}),
).pipe(Effect.provide(live(none))),
).pipe(Effect.provide(wired(none))),
{ config: { enterprise: { url: "https://legacy-share.example.com" } } },
),
)
@@ -119,7 +109,7 @@ describe("ShareNext", () => {
expect(req.api.create).toBe("/api/share")
expect(req.headers).toEqual({})
}),
).pipe(Effect.provide(live(none))),
).pipe(Effect.provide(wired(none))),
),
)
@@ -128,7 +118,7 @@ describe("ShareNext", () => {
Effect.gen(function* () {
yield* seed("https://control.example.com", "org-1")
const req = yield* ShareNext.Service.use((svc) => svc.request()).pipe(Effect.provide(live(none)))
const req = yield* ShareNext.Service.use((svc) => svc.request())
expect(req.api.create).toBe("/api/shares")
expect(req.api.sync("shr_123")).toBe("/api/shares/shr_123/sync")
@@ -139,33 +129,33 @@ describe("ShareNext", () => {
authorization: "Bearer st_test_token",
"x-org-id": "org-1",
})
}),
}).pipe(Effect.provide(wired(none))),
),
)
it.live("create posts share, persists it, and returns the result", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const session = yield* Session.Service.use((svc) => svc.create({ title: "test" }))
const seen: HttpClientRequest.HttpClientRequest[] = []
const client = HttpClient.make((req) => {
seen.push(req)
if (req.url.endsWith("/api/share")) {
return Effect.succeed(
json(req, {
id: "shr_abc",
url: "https://legacy-share.example.com/share/abc",
secret: "sec_123",
}),
)
}
return Effect.succeed(json(req, { ok: true }))
})
() => {
const seen: HttpClientRequest.HttpClientRequest[] = []
const client = HttpClient.make((req) => {
seen.push(req)
if (req.url.endsWith("/api/share")) {
return Effect.succeed(
json(req, {
id: "shr_abc",
url: "https://legacy-share.example.com/share/abc",
secret: "sec_123",
}),
)
}
return Effect.succeed(json(req, { ok: true }))
})
const result = yield* ShareNext.Service.use((svc) => svc.create(session.id)).pipe(
Effect.provide(live(client)),
)
return Effect.gen(function* () {
const sessions = yield* Session.Service
const shareNext = yield* ShareNext.Service
const session = yield* sessions.create({ title: "test" })
const result = yield* shareNext.create(session.id)
expect(result.id).toBe("shr_abc")
expect(result.url).toBe("https://legacy-share.example.com/share/abc")
@@ -179,60 +169,61 @@ describe("ShareNext", () => {
expect(seen).toHaveLength(1)
expect(seen[0].method).toBe("POST")
expect(seen[0].url).toBe("https://legacy-share.example.com/api/share")
}),
}).pipe(Effect.provide(wired(client)))
},
{ config: { enterprise: { url: "https://legacy-share.example.com" } } },
),
)
it.live("remove deletes the persisted share and calls the delete endpoint", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const session = yield* Session.Service.use((svc) => svc.create({ title: "test" }))
const seen: HttpClientRequest.HttpClientRequest[] = []
const client = HttpClient.make((req) => {
seen.push(req)
if (req.method === "POST") {
return Effect.succeed(
json(req, {
id: "shr_abc",
url: "https://legacy-share.example.com/share/abc",
secret: "sec_123",
}),
)
}
return Effect.succeed(HttpClientResponse.fromWeb(req, new Response(null, { status: 200 })))
})
yield* Effect.gen(function* () {
yield* ShareNext.Service.use((svc) => svc.create(session.id))
yield* ShareNext.Service.use((svc) => svc.remove(session.id))
}).pipe(Effect.provide(live(client)))
() => {
const seen: HttpClientRequest.HttpClientRequest[] = []
const client = HttpClient.make((req) => {
seen.push(req)
if (req.method === "POST") {
return Effect.succeed(
json(req, {
id: "shr_abc",
url: "https://legacy-share.example.com/share/abc",
secret: "sec_123",
}),
)
}
return Effect.succeed(HttpClientResponse.fromWeb(req, new Response(null, { status: 200 })))
})
return Effect.gen(function* () {
const sessions = yield* Session.Service
const shareNext = yield* ShareNext.Service
const session = yield* sessions.create({ title: "test" })
yield* shareNext.create(session.id)
yield* shareNext.remove(session.id)
expect(share(session.id)).toBeUndefined()
expect(seen.map((req) => [req.method, req.url])).toEqual([
["POST", "https://legacy-share.example.com/api/share"],
["DELETE", "https://legacy-share.example.com/api/share/shr_abc"],
])
}),
}).pipe(Effect.provide(wired(client)))
},
{ config: { enterprise: { url: "https://legacy-share.example.com" } } },
),
)
it.live("create fails on a non-ok response and does not persist a share", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const session = yield* Session.Service.use((svc) => svc.create({ title: "test" }))
const client = HttpClient.make((req) => Effect.succeed(json(req, { error: "bad" }, 500)))
provideTmpdirInstance(() => {
const client = HttpClient.make((req) => Effect.succeed(json(req, { error: "bad" }, 500)))
const exit = yield* ShareNext.Service.use((svc) => Effect.exit(svc.create(session.id))).pipe(
Effect.provide(live(client)),
)
return Effect.gen(function* () {
const sessions = yield* Session.Service
const shareNext = yield* ShareNext.Service
const session = yield* sessions.create({ title: "test" })
const exit = yield* Effect.exit(shareNext.create(session.id))
expect(Exit.isFailure(exit)).toBe(true)
expect(share(session.id)).toBeUndefined()
}),
),
}).pipe(Effect.provide(wired(client)))
}),
)
it.live("ShareNext coalesces rapid diff events into one delayed sync with latest data", () =>

View File

@@ -0,0 +1,73 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect, ManagedRuntime } from "effect"
import { Database } from "@/storage/db"
import { DatabaseEffect } from "@/storage/db-effect"
import { resetDatabase } from "../fixture/db"
afterEach(async () => {
await resetDatabase()
})
describe("DatabaseEffect.layer", () => {
test("yields a working Service that round-trips a query", async () => {
const rt = ManagedRuntime.make(DatabaseEffect.layer)
try {
const value = await rt.runPromise(
Effect.gen(function* () {
const db = yield* DatabaseEffect.Service
return db.$client.prepare("SELECT 42 as n").get() as { n: number }
}),
)
expect(value).toEqual({ n: 42 })
} finally {
await rt.dispose()
}
})
test("service resolves a fresh handle after Database.close", async () => {
const rt = ManagedRuntime.make(DatabaseEffect.layer)
const first = await rt.runPromise(Effect.sync(() => Database.Client().$client))
expect(first.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
Database.close()
try {
const second = await rt.runPromise(
Effect.gen(function* () {
const db = yield* DatabaseEffect.Service
return db.$client
}),
)
expect(second).not.toBe(first)
expect(second.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
} finally {
await rt.dispose()
}
})
test("a runtime kept alive over Database.close uses the refreshed handle", async () => {
const rt = ManagedRuntime.make(DatabaseEffect.layer)
const captured = await rt.runPromise(
Effect.gen(function* () {
const db = yield* DatabaseEffect.Service
return db.$client
}),
)
expect(captured.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
Database.close()
try {
const fresh = await rt.runPromise(
Effect.gen(function* () {
const db = yield* DatabaseEffect.Service
return db.$client
}),
)
expect(fresh).not.toBe(captured)
expect(fresh.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
} finally {
await rt.dispose()
}
})
})

View File

@@ -12,12 +12,10 @@ import { createClient } from "@hey-api/openapi-ts"
const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi"
const opencode = path.resolve(dir, "../../opencode")
// `bun dev generate` now derives the spec from the Effect HttpApi contract by
// default; pass `--hono` to fall back to the legacy Hono spec for parity diffs.
if (openapiSource === "httpapi") {
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
await $`bun dev generate --httpapi > ${dir}/openapi.json`.cwd(opencode)
} else {
await $`bun dev generate --hono > ${dir}/openapi.json`.cwd(opencode)
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
}
await createClient({

File diff suppressed because it is too large Load Diff