mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d037966f4 | |||
| 10089c9d28 | |||
| f8232986d6 | |||
| cea7b7e182 | |||
| 33ac63f5b8 | |||
| 05346fdc50 |
@@ -1,24 +0,0 @@
|
||||
name: close-issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *" # Daily at 2:00 AM
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
close:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Close stale issues
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bun script/github/close-issues.ts
|
||||
@@ -0,0 +1,33 @@
|
||||
name: stale-issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *" # Daily at 1:30 AM
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DAYS_BEFORE_STALE: 90
|
||||
DAYS_BEFORE_CLOSE: 7
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
|
||||
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
|
||||
stale-issue-label: "stale"
|
||||
close-issue-message: |
|
||||
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
|
||||
|
||||
Feel free to reopen if you still need this!
|
||||
stale-issue-message: |
|
||||
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
|
||||
|
||||
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
|
||||
remove-stale-when-updated: true
|
||||
exempt-issue-labels: "pinned,security,feature-request,on-hold"
|
||||
start-date: "2025-12-27"
|
||||
@@ -329,7 +329,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
@@ -383,6 +383,7 @@
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"ws": "8.18.0",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
@@ -410,6 +411,7 @@
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
@@ -1325,7 +1327,7 @@
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
|
||||
|
||||
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
|
||||
|
||||
@@ -2889,7 +2891,7 @@
|
||||
|
||||
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
|
||||
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
||||
|
||||
"expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
|
||||
|
||||
@@ -5129,8 +5131,6 @@
|
||||
|
||||
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
@@ -6313,8 +6313,6 @@
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
@@ -496,6 +496,7 @@ async function subscribeSessionEvents() {
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
||||
todoread: ["Todo", "\x1b[33m\x1b[1m"],
|
||||
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
||||
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
||||
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-l3k/1fRIAQkj7zdVj2Ad3QZWeTOf1CuIM6vgMHRaK1s=",
|
||||
"aarch64-linux": "sha256-iN3YtrKAUTK1GIwVMoVYkMXhtDZOiP7sSJ+Z8v4B5xw=",
|
||||
"aarch64-darwin": "sha256-FFedoiPyfHGdzQnITz1SRV7xv2XoT9vzxIDp4EcVdkU=",
|
||||
"x86_64-darwin": "sha256-0HiHkhJiN73UixUq5CC6YP6DkZzLar8lKnEL1aoiiHg="
|
||||
"x86_64-linux": "sha256-MmN2+NfHeLPDClpLPOlCAZTmwI94M6XgNAqXrW5Ls4I=",
|
||||
"aarch64-linux": "sha256-whVIlmDvoMmEMUY2Yxx2vAmFDuKQic6ChY1V+9gLd84=",
|
||||
"aarch64-darwin": "sha256-TulGiC24w3usk26hKr3PyccatvIfmAlHgEJaOTUf3pQ=",
|
||||
"x86_64-darwin": "sha256-T8NWm0bBybJKThRdp/jQdxilv1Ec9SF1iVT3udSoZOg="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { execSync } from "node:child_process"
|
||||
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
||||
import { createSdk, modKey, resolveDirectory, serverUrl, workspaceKey } from "./utils"
|
||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||
import {
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
@@ -438,7 +438,7 @@ export async function resolveSlug(slug: string) {
|
||||
}
|
||||
|
||||
export async function waitDir(page: Page, directory: string) {
|
||||
const target = workspaceKey(await resolveDirectory(directory))
|
||||
const target = await resolveDirectory(directory)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
@@ -446,7 +446,7 @@ export async function waitDir(page: Page, directory: string) {
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
return resolveSlug(slug)
|
||||
.then((item) => workspaceKey(item.directory))
|
||||
.then((item) => item.directory)
|
||||
.catch(() => "")
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
@@ -456,7 +456,7 @@ export async function waitDir(page: Page, directory: string) {
|
||||
}
|
||||
|
||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
||||
const target = workspaceKey(await resolveDirectory(input.directory))
|
||||
const target = await resolveDirectory(input.directory)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
@@ -464,14 +464,14 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return false
|
||||
const resolved = await resolveSlug(slug).catch(() => undefined)
|
||||
if (!resolved || workspaceKey(resolved.directory) !== target) return false
|
||||
if (!resolved || resolved.directory !== target) return false
|
||||
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
|
||||
|
||||
const state = await probeSession(page)
|
||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||
if (state?.dir) {
|
||||
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
||||
if (workspaceKey(dir) !== target) return false
|
||||
if (dir !== target) return false
|
||||
}
|
||||
|
||||
return page
|
||||
@@ -488,7 +488,7 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
||||
|
||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
||||
const sdk = createSdk(directory)
|
||||
const target = workspaceKey(await resolveDirectory(directory))
|
||||
const target = await resolveDirectory(directory)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -498,9 +498,7 @@ export async function waitSessionSaved(directory: string, sessionID: string, tim
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!data?.directory) return ""
|
||||
return resolveDirectory(data.directory)
|
||||
.then(workspaceKey)
|
||||
.catch(() => workspaceKey(data.directory))
|
||||
return resolveDirectory(data.directory).catch(() => data.directory)
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
|
||||
@@ -36,14 +36,6 @@ export async function resolveDirectory(directory: string) {
|
||||
.then((x) => x.data?.directory ?? directory)
|
||||
}
|
||||
|
||||
export function workspaceKey(dir: string) {
|
||||
const value = dir.replaceAll("\\", "/")
|
||||
const drive = value.match(/^([A-Za-z]:)\/+$/)
|
||||
if (drive) return `${drive[1]}/`
|
||||
if (/^\/+$/i.test(value)) return "/"
|
||||
return value.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
export async function getWorktree() {
|
||||
const sdk = createSdk()
|
||||
const result = await sdk.path.get()
|
||||
@@ -65,8 +57,7 @@ export function sessionPath(directory: string, sessionID?: string) {
|
||||
}
|
||||
|
||||
export function workspacePersistKey(directory: string, key: string) {
|
||||
const dir = workspaceKey(directory)
|
||||
const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const sum = checksum(dir) ?? "0"
|
||||
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const sum = checksum(directory) ?? "0"
|
||||
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
const workers =
|
||||
Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? (process.platform === "win32" ? 2 : 5) : 0)) || undefined
|
||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
|
||||
@@ -32,25 +32,6 @@ describe("command keybind helpers", () => {
|
||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("matchKeybind supports bracket keys", () => {
|
||||
const keybinds = parseKeybind("mod+alt+[, mod+alt+]")
|
||||
const prev = keybinds[0]
|
||||
const next = keybinds[1]
|
||||
|
||||
expect(
|
||||
matchKeybind(
|
||||
keybinds,
|
||||
new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
matchKeybind(
|
||||
keybinds,
|
||||
new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("formatKeybind returns human readable output", () => {
|
||||
const display = formatKeybind("ctrl+alt+arrowup")
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { retry } from "@opencode-ai/util/retry"
|
||||
import { batch } from "solid-js"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import { cmp, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
type GlobalStore = {
|
||||
@@ -174,7 +174,7 @@ export async function bootstrapDirectory(input: {
|
||||
seededProject
|
||||
? Promise.resolve()
|
||||
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))),
|
||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
() =>
|
||||
retry(() =>
|
||||
@@ -190,7 +190,7 @@ export async function bootstrapDirectory(input: {
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||
|
||||
@@ -494,10 +494,8 @@ describe("applyDirectoryEvent", () => {
|
||||
})
|
||||
|
||||
test("updates vcs branch in store and cache", () => {
|
||||
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
||||
const [cacheStore, setCacheStore] = createStore({
|
||||
value: { branch: "main", default_branch: "main" } as State["vcs"],
|
||||
})
|
||||
const [store, setStore] = createStore(baseState())
|
||||
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
||||
@@ -513,8 +511,8 @@ describe("applyDirectoryEvent", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||
expect(store.vcs).toEqual({ branch: "feature/test" })
|
||||
expect(cacheStore.value).toEqual({ branch: "feature/test" })
|
||||
})
|
||||
|
||||
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||
|
||||
@@ -271,9 +271,9 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "vcs.branch.updated": {
|
||||
const props = event.properties as { branch?: string }
|
||||
const props = event.properties as { branch: string }
|
||||
if (input.store.vcs?.branch === props.branch) break
|
||||
const next = { ...input.store.vcs, branch: props.branch }
|
||||
const next = { branch: props.branch }
|
||||
input.setStore("vcs", next)
|
||||
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
||||
break
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Agent } from "@opencode-ai/sdk/v2/client"
|
||||
import { normalizeAgentList } from "./utils"
|
||||
|
||||
const agent = (name = "build") =>
|
||||
({
|
||||
name,
|
||||
mode: "primary",
|
||||
permission: {},
|
||||
options: {},
|
||||
}) as Agent
|
||||
|
||||
describe("normalizeAgentList", () => {
|
||||
test("keeps array payloads", () => {
|
||||
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
|
||||
})
|
||||
|
||||
test("wraps a single agent payload", () => {
|
||||
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
|
||||
})
|
||||
|
||||
test("extracts agents from keyed objects", () => {
|
||||
expect(
|
||||
normalizeAgentList({
|
||||
build: agent("build"),
|
||||
docs: agent("docs"),
|
||||
}),
|
||||
).toEqual([agent("build"), agent("docs")])
|
||||
})
|
||||
|
||||
test("drops invalid payloads", () => {
|
||||
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
|
||||
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,7 @@
|
||||
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
function isAgent(input: unknown): input is Agent {
|
||||
if (!input || typeof input !== "object") return false
|
||||
const item = input as { name?: unknown; mode?: unknown }
|
||||
if (typeof item.name !== "string") return false
|
||||
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
|
||||
}
|
||||
|
||||
export function normalizeAgentList(input: unknown): Agent[] {
|
||||
if (Array.isArray(input)) return input.filter(isAgent)
|
||||
if (isAgent(input)) return [input]
|
||||
if (!input || typeof input !== "object") return []
|
||||
return Object.values(input).filter(isAgent)
|
||||
}
|
||||
|
||||
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
||||
return {
|
||||
...input,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useModels } from "@/context/models"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { modelEnabled, modelProbe } from "@/testing/model-selection"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { workspaceKey } from "@/utils/workspace"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
@@ -27,7 +26,7 @@ type Saved = {
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const handoff = new Map<string, State>()
|
||||
|
||||
const handoffKey = (dir: string, id: string) => `${workspaceKey(dir)}\n${id}`
|
||||
const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
|
||||
|
||||
const migrate = (value: unknown) => {
|
||||
if (!value || typeof value !== "object") return { session: {} }
|
||||
@@ -365,7 +364,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const next = clone(snapshot())
|
||||
if (!next) return
|
||||
|
||||
if (workspaceKey(dir) === workspaceKey(sdk.directory)) {
|
||||
if (dir === sdk.directory) {
|
||||
setSaved("session", session, next)
|
||||
setStore("draft", undefined)
|
||||
return
|
||||
|
||||
@@ -97,10 +97,15 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
|
||||
throw new Error(getRootNotFoundError())
|
||||
}
|
||||
|
||||
const localUrl = () =>
|
||||
`http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
|
||||
const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname)
|
||||
|
||||
const getCurrentUrl = () => {
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
if (location.hostname.includes("opencode.ai")) return localUrl()
|
||||
if (import.meta.env.DEV) return localUrl()
|
||||
if (isLocalHost()) return localUrl()
|
||||
return location.origin
|
||||
}
|
||||
|
||||
|
||||
@@ -722,6 +722,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
|
||||
"settings.permissions.tool.todoread.title": "قراءة المهام",
|
||||
"settings.permissions.tool.todoread.description": "قراءة قائمة المهام",
|
||||
"settings.permissions.tool.todowrite.title": "كتابة المهام",
|
||||
"settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
|
||||
"settings.permissions.tool.webfetch.title": "جلب الويب",
|
||||
|
||||
@@ -732,6 +732,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
|
||||
"settings.permissions.tool.todoread.title": "Ler Tarefas",
|
||||
"settings.permissions.tool.todoread.description": "Ler a lista de tarefas",
|
||||
"settings.permissions.tool.todowrite.title": "Escrever Tarefas",
|
||||
"settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
|
||||
"settings.permissions.tool.webfetch.title": "Buscar Web",
|
||||
|
||||
@@ -806,6 +806,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
|
||||
"settings.permissions.tool.todoread.title": "Čitanje liste zadataka",
|
||||
"settings.permissions.tool.todoread.description": "Čitanje liste zadataka",
|
||||
"settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
|
||||
"settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
|
||||
"settings.permissions.tool.webfetch.title": "Web preuzimanje",
|
||||
|
||||
@@ -800,6 +800,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
|
||||
"settings.permissions.tool.todoread.title": "Læs To-do",
|
||||
"settings.permissions.tool.todoread.description": "Læs to-do listen",
|
||||
"settings.permissions.tool.todowrite.title": "Skriv To-do",
|
||||
"settings.permissions.tool.todowrite.description": "Opdater to-do listen",
|
||||
"settings.permissions.tool.webfetch.title": "Webhentning",
|
||||
|
||||
@@ -743,6 +743,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
|
||||
"settings.permissions.tool.todoread.title": "Todo lesen",
|
||||
"settings.permissions.tool.todoread.description": "Die Todo-Liste lesen",
|
||||
"settings.permissions.tool.todowrite.title": "Todo schreiben",
|
||||
"settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
|
||||
"settings.permissions.tool.webfetch.title": "Web-Abruf",
|
||||
|
||||
@@ -535,8 +535,6 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.action": "Create Git repository",
|
||||
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
||||
"session.review.noChanges": "No changes",
|
||||
"session.review.noUncommittedChanges": "No uncommitted changes yet",
|
||||
"session.review.noBranchChanges": "No branch changes yet",
|
||||
|
||||
"session.files.selectToOpen": "Select a file to open",
|
||||
"session.files.all": "All files",
|
||||
@@ -902,6 +900,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Load a skill by name",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Run language server queries",
|
||||
"settings.permissions.tool.todoread.title": "Todo Read",
|
||||
"settings.permissions.tool.todoread.description": "Read the todo list",
|
||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||
"settings.permissions.tool.todowrite.description": "Update the todo list",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
|
||||
@@ -813,6 +813,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Cargar una habilidad por nombre",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
|
||||
"settings.permissions.tool.todoread.title": "Leer Todo",
|
||||
"settings.permissions.tool.todoread.description": "Leer la lista de tareas",
|
||||
"settings.permissions.tool.todowrite.title": "Escribir Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
|
||||
@@ -741,6 +741,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Charger une compétence par son nom",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
|
||||
"settings.permissions.tool.todoread.title": "Lire Todo",
|
||||
"settings.permissions.tool.todoread.description": "Lire la liste de tâches",
|
||||
"settings.permissions.tool.todowrite.title": "Écrire Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
|
||||
"settings.permissions.tool.webfetch.title": "Récupération Web",
|
||||
|
||||
@@ -727,6 +727,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "名前によるスキルの読み込み",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
|
||||
"settings.permissions.tool.todoread.title": "Todo読み込み",
|
||||
"settings.permissions.tool.todoread.description": "Todoリストの読み込み",
|
||||
"settings.permissions.tool.todowrite.title": "Todo書き込み",
|
||||
"settings.permissions.tool.todowrite.description": "Todoリストの更新",
|
||||
"settings.permissions.tool.webfetch.title": "Web取得",
|
||||
|
||||
@@ -726,6 +726,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "이름으로 기술 로드",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
|
||||
"settings.permissions.tool.todoread.title": "할 일 읽기",
|
||||
"settings.permissions.tool.todoread.description": "할 일 목록 읽기",
|
||||
"settings.permissions.tool.todowrite.title": "할 일 쓰기",
|
||||
"settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
|
||||
"settings.permissions.tool.webfetch.title": "웹 가져오기",
|
||||
|
||||
@@ -807,6 +807,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Last en ferdighet etter navn",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
|
||||
"settings.permissions.tool.todoread.title": "Les gjøremål",
|
||||
"settings.permissions.tool.todoread.description": "Les gjøremålslisten",
|
||||
"settings.permissions.tool.todowrite.title": "Skriv gjøremål",
|
||||
"settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
|
||||
"settings.permissions.tool.webfetch.title": "Webhenting",
|
||||
|
||||
@@ -729,6 +729,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
|
||||
"settings.permissions.tool.todoread.title": "Odczyt Todo",
|
||||
"settings.permissions.tool.todoread.description": "Odczyt listy zadań",
|
||||
"settings.permissions.tool.todowrite.title": "Zapis Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
|
||||
"settings.permissions.tool.webfetch.title": "Pobieranie z sieci",
|
||||
|
||||
@@ -808,6 +808,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Загрузка навыка по имени",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
|
||||
"settings.permissions.tool.todoread.title": "Todo Read",
|
||||
"settings.permissions.tool.todoread.description": "Чтение списка задач",
|
||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||
"settings.permissions.tool.todowrite.description": "Обновление списка задач",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
|
||||
@@ -796,6 +796,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
|
||||
"settings.permissions.tool.todoread.title": "อ่านรายการงาน",
|
||||
"settings.permissions.tool.todoread.description": "อ่านรายการงาน",
|
||||
"settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
|
||||
"settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
|
||||
"settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",
|
||||
|
||||
@@ -816,6 +816,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
|
||||
"settings.permissions.tool.todoread.title": "Görev Oku",
|
||||
"settings.permissions.tool.todoread.description": "Görev listesini oku",
|
||||
"settings.permissions.tool.todowrite.title": "Görev Yaz",
|
||||
"settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
|
||||
"settings.permissions.tool.webfetch.title": "Web Getir",
|
||||
|
||||
@@ -795,6 +795,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "按名称加载技能",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "运行语言服务器查询",
|
||||
"settings.permissions.tool.todoread.title": "读取待办",
|
||||
"settings.permissions.tool.todoread.description": "读取待办列表",
|
||||
"settings.permissions.tool.todowrite.title": "更新待办",
|
||||
"settings.permissions.tool.todowrite.description": "更新待办列表",
|
||||
"settings.permissions.tool.webfetch.title": "网页获取",
|
||||
|
||||
@@ -790,6 +790,8 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "按名稱載入技能",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
|
||||
"settings.permissions.tool.todoread.title": "讀取待辦",
|
||||
"settings.permissions.tool.todoread.description": "讀取待辦清單",
|
||||
"settings.permissions.tool.todowrite.title": "更新待辦",
|
||||
"settings.permissions.tool.todowrite.description": "更新待辦清單",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
|
||||
@@ -965,8 +965,6 @@ export default function Layout(props: ParentProps) {
|
||||
: projects[(index + offset + projects.length) % projects.length]
|
||||
if (!target) return
|
||||
|
||||
// warm up child store to prevent flicker
|
||||
globalSync.child(target.worktree)
|
||||
openProject(target.worktree)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
export { workspaceKey } from "@/utils/workspace"
|
||||
import { workspaceKey } from "@/utils/workspace"
|
||||
|
||||
type SessionStore = {
|
||||
session?: Session[]
|
||||
path: { directory: string }
|
||||
}
|
||||
|
||||
export const workspaceKey = (directory: string) => {
|
||||
const value = directory.replaceAll("\\", "/")
|
||||
const drive = value.match(/^([A-Za-z]:)\/+$/)
|
||||
if (drive) return `${drive[1]}/`
|
||||
if (/^\/+$/i.test(value)) return "/"
|
||||
return value.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function sortSessions(now: number) {
|
||||
const oneMinuteAgo = now - 60 * 1000
|
||||
return (a: Session, b: Session) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import {
|
||||
@@ -64,9 +64,6 @@ import { formatServerError } from "@/utils/server-errors"
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const emptyFollowups: (FollowupDraft & { id: string })[] = []
|
||||
|
||||
type ChangeMode = "git" | "branch" | "session" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
@@ -427,16 +424,15 @@ export default function Page() {
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
||||
const canReview = createMemo(() => !!params.id)
|
||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview: canReview,
|
||||
hasReview,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
@@ -459,12 +455,6 @@ export default function Page() {
|
||||
if (!id) return false
|
||||
return sync.session.history.loading(id)
|
||||
})
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasSessionReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
@@ -494,7 +484,7 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [params.id, lastUserMessage()?.id] as const,
|
||||
() => lastUserMessage()?.id,
|
||||
() => {
|
||||
const msg = lastUserMessage()
|
||||
if (!msg) return
|
||||
@@ -518,22 +508,11 @@ export default function Page() {
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "git" as ChangeMode,
|
||||
changes: "session" as "session" | "turn",
|
||||
newSessionWorktree: "main",
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
const [vcs, setVcs] = createStore({
|
||||
diff: {
|
||||
git: [] as FileDiff[],
|
||||
branch: [] as FileDiff[],
|
||||
},
|
||||
ready: {
|
||||
git: false,
|
||||
branch: false,
|
||||
},
|
||||
})
|
||||
|
||||
const [followup, setFollowup] = createStore({
|
||||
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
||||
failed: {} as Record<string, string | undefined>,
|
||||
@@ -560,68 +539,6 @@ export default function Page() {
|
||||
let refreshTimer: number | undefined
|
||||
let diffFrame: number | undefined
|
||||
let diffTimer: number | undefined
|
||||
const vcsTask = new Map<VcsMode, Promise<void>>()
|
||||
const vcsRun = new Map<VcsMode, number>()
|
||||
|
||||
const bumpVcs = (mode: VcsMode) => {
|
||||
const next = (vcsRun.get(mode) ?? 0) + 1
|
||||
vcsRun.set(mode, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const resetVcs = (mode?: VcsMode) => {
|
||||
const list = mode ? [mode] : (["git", "branch"] as const)
|
||||
list.forEach((item) => {
|
||||
bumpVcs(item)
|
||||
vcsTask.delete(item)
|
||||
setVcs("diff", item, [])
|
||||
setVcs("ready", item, false)
|
||||
})
|
||||
}
|
||||
|
||||
const loadVcs = (mode: VcsMode, force = false) => {
|
||||
if (sync.project?.vcs !== "git") return Promise.resolve()
|
||||
if (!force && vcs.ready[mode]) return Promise.resolve()
|
||||
|
||||
if (force) {
|
||||
if (vcsTask.has(mode)) bumpVcs(mode)
|
||||
vcsTask.delete(mode)
|
||||
setVcs("ready", mode, false)
|
||||
}
|
||||
|
||||
const current = vcsTask.get(mode)
|
||||
if (current) return current
|
||||
|
||||
const run = bumpVcs(mode)
|
||||
|
||||
const task = sdk.client.vcs
|
||||
.diff({ mode })
|
||||
.then((result) => {
|
||||
if (vcsRun.get(mode) !== run) return
|
||||
setVcs("diff", mode, result.data ?? [])
|
||||
setVcs("ready", mode, true)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (vcsRun.get(mode) !== run) return
|
||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||
setVcs("diff", mode, [])
|
||||
setVcs("ready", mode, true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
|
||||
})
|
||||
|
||||
vcsTask.set(mode, task)
|
||||
return task
|
||||
}
|
||||
|
||||
const refreshVcs = () => {
|
||||
resetVcs()
|
||||
const mode = untrack(vcsMode)
|
||||
if (!mode) return
|
||||
if (!untrack(wantsReview)) return
|
||||
void loadVcs(mode, true)
|
||||
}
|
||||
|
||||
createComputed((prev) => {
|
||||
const open = desktopReviewOpen()
|
||||
@@ -637,42 +554,7 @@ export default function Page() {
|
||||
}, desktopReviewOpen())
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||
const list: ChangeMode[] = []
|
||||
if (sync.project?.vcs === "git") list.push("git")
|
||||
if (
|
||||
sync.project?.vcs === "git" &&
|
||||
sync.data.vcs?.branch &&
|
||||
sync.data.vcs?.default_branch &&
|
||||
sync.data.vcs.branch !== sync.data.vcs.default_branch
|
||||
) {
|
||||
list.push("branch")
|
||||
}
|
||||
list.push("session", "turn")
|
||||
return list
|
||||
})
|
||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||
if (store.changes === "git" || store.changes === "branch") return store.changes
|
||||
})
|
||||
const reviewDiffs = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.diff.git
|
||||
if (store.changes === "branch") return vcs.diff.branch
|
||||
if (store.changes === "session") return diffs()
|
||||
return turnDiffs()
|
||||
})
|
||||
const reviewCount = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.diff.git.length
|
||||
if (store.changes === "branch") return vcs.diff.branch.length
|
||||
if (store.changes === "session") return sessionCount()
|
||||
return turnDiffs().length
|
||||
})
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const reviewReady = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.ready.git
|
||||
if (store.changes === "branch") return vcs.ready.branch
|
||||
if (store.changes === "session") return !hasSessionReview() || diffsReady()
|
||||
return true
|
||||
})
|
||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
@@ -738,7 +620,13 @@ export default function Page() {
|
||||
scrollToMessage(msgs[targetIndex], "auto")
|
||||
}
|
||||
|
||||
const sessionEmptyKey = createMemo(() => {
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
const reviewEmptyKey = createMemo(() => {
|
||||
const project = sync.project
|
||||
if (project && !project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
@@ -860,46 +748,13 @@ export default function Page() {
|
||||
sessionKey,
|
||||
() => {
|
||||
setStore("messageId", undefined)
|
||||
setStore("changes", "git")
|
||||
setStore("changes", "session")
|
||||
setUi("pendingMessage", undefined)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sdk.directory,
|
||||
() => {
|
||||
resetVcs()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
|
||||
(next, prev) => {
|
||||
if (prev === undefined || same(next, prev)) return
|
||||
refreshVcs()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const stopVcs = sdk.event.listen((evt) => {
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
? (evt.details.properties as Record<string, unknown>)
|
||||
: undefined
|
||||
const file = typeof props?.file === "string" ? props.file : undefined
|
||||
if (!file || file.startsWith(".git/")) return
|
||||
refreshVcs()
|
||||
})
|
||||
onCleanup(stopVcs)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.dir,
|
||||
@@ -1022,40 +877,6 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||
const wantsReview = createMemo(() =>
|
||||
isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes",
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const list = changesOptions()
|
||||
if (list.includes(store.changes)) return
|
||||
const next = list[0]
|
||||
if (!next) return
|
||||
setStore("changes", next)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const mode = vcsMode()
|
||||
if (!mode) return
|
||||
if (!wantsReview()) return
|
||||
void loadVcs(mode)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.data.session_status[params.id ?? ""]?.type,
|
||||
(next, prev) => {
|
||||
const mode = vcsMode()
|
||||
if (!mode) return
|
||||
if (!wantsReview()) return
|
||||
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||
void loadVcs(mode, true)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const fileTreeTab = () => layout.fileTree.tab()
|
||||
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
|
||||
@@ -1102,23 +923,21 @@ export default function Page() {
|
||||
loadFile: file.load,
|
||||
})
|
||||
|
||||
const changesTitle = () => {
|
||||
if (!canReview()) {
|
||||
return null
|
||||
}
|
||||
const changesOptions = ["session", "turn"] as const
|
||||
const changesOptionsList = [...changesOptions]
|
||||
|
||||
const label = (option: ChangeMode) => {
|
||||
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||
if (option === "session") return language.t("ui.sessionReview.title")
|
||||
return language.t("ui.sessionReview.title.lastTurn")
|
||||
const changesTitle = () => {
|
||||
if (!hasReview()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={changesOptions()}
|
||||
options={changesOptionsList}
|
||||
current={store.changes}
|
||||
label={label}
|
||||
label={(option) =>
|
||||
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
@@ -1127,34 +946,20 @@ export default function Page() {
|
||||
)
|
||||
}
|
||||
|
||||
const empty = (text: string) => (
|
||||
const emptyTurn = () => (
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const reviewEmptyText = createMemo(() => {
|
||||
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
|
||||
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
|
||||
if (store.changes === "turn") return language.t("session.review.noChanges")
|
||||
return language.t(sessionEmptyKey())
|
||||
})
|
||||
|
||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||
if (store.changes === "git" || store.changes === "branch") {
|
||||
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
return empty(reviewEmptyText())
|
||||
}
|
||||
if (store.changes === "turn") return emptyTurn()
|
||||
|
||||
if (store.changes === "turn") {
|
||||
return empty(reviewEmptyText())
|
||||
}
|
||||
|
||||
if (hasSessionReview() && !diffsReady()) {
|
||||
if (hasReview() && !diffsReady()) {
|
||||
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
}
|
||||
|
||||
if (sessionEmptyKey() === "session.review.noVcs") {
|
||||
if (reviewEmptyKey() === "session.review.noVcs") {
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -1174,7 +979,7 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1278,7 +1083,7 @@ export default function Page() {
|
||||
const pending = tree.pendingDiff
|
||||
if (!pending) return
|
||||
if (!tree.reviewScroll) return
|
||||
if (!reviewReady()) return
|
||||
if (!diffsReady()) return
|
||||
|
||||
const attempt = (count: number) => {
|
||||
if (tree.pendingDiff !== pending) return
|
||||
@@ -1319,7 +1124,10 @@ export default function Page() {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
|
||||
if (!wantsReview()) return
|
||||
const wants = isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes"
|
||||
if (!wants) return
|
||||
if (sync.data.session_diff[id] !== undefined) return
|
||||
if (sync.status === "loading") return
|
||||
|
||||
@@ -1328,7 +1136,13 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [sessionKey(), wantsReview()] as const,
|
||||
() =>
|
||||
[
|
||||
sessionKey(),
|
||||
isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes",
|
||||
] as const,
|
||||
([key, wants]) => {
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||
@@ -2014,12 +1828,6 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanel}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
|
||||
@@ -896,8 +896,7 @@ export function MessageTimeline(props: {
|
||||
</Show>
|
||||
<div
|
||||
role="log"
|
||||
data-slot="session-turn-list"
|
||||
class="flex flex-col items-start justify-start pb-16 transition-[margin]"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
@@ -20,6 +19,7 @@ import { useCommand } from "@/context/command"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||
@@ -27,12 +27,6 @@ import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => FileDiff[]
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
reviewCount: () => number
|
||||
reviewPanel: () => JSX.Element
|
||||
activeDiff?: string
|
||||
focusReviewDiff: (path: string) => void
|
||||
@@ -40,11 +34,12 @@ export function SessionSidePanel(props: {
|
||||
size: Sizing
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const sync = useSync()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const { sessionKey, tabs, view } = useSessionLayout()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
@@ -59,7 +54,24 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||
|
||||
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const reviewEmptyKey = createMemo(() => {
|
||||
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
return "session.review.noChanges"
|
||||
})
|
||||
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
if (!a) return b
|
||||
@@ -70,7 +82,7 @@ export function SessionSidePanel(props: {
|
||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||
|
||||
const out = new Map<string, "add" | "del" | "mix">()
|
||||
for (const diff of props.diffs()) {
|
||||
for (const diff of diffs()) {
|
||||
const file = normalize(diff.file)
|
||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||
|
||||
@@ -124,7 +136,7 @@ export function SessionSidePanel(props: {
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview: props.canReview,
|
||||
hasReview,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
@@ -229,12 +241,12 @@ export function SessionSidePanel(props: {
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Show when={reviewTab()}>
|
||||
<Tabs.Trigger value="review">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={props.hasReview()}>
|
||||
<div>{props.reviewCount()}</div>
|
||||
<Show when={hasReview()}>
|
||||
<div>{reviewCount()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
@@ -291,7 +303,7 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Show when={reviewTab()}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
@@ -365,10 +377,8 @@ export function SessionSidePanel(props: {
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{props.reviewCount()}{" "}
|
||||
{language.t(
|
||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
{reviewCount()}{" "}
|
||||
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{language.t("session.files.all")}
|
||||
@@ -376,9 +386,9 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={props.diffsReady()}
|
||||
when={diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
@@ -397,7 +407,11 @@ export function SessionSidePanel(props: {
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>{empty(props.empty())}</Match>
|
||||
<Match when={true}>
|
||||
{empty(
|
||||
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||
|
||||
@@ -56,7 +56,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
if (!id) return
|
||||
return sync.session.get(id)
|
||||
}
|
||||
const hasReview = () => !!params.id
|
||||
const hasReview = () => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
|
||||
}
|
||||
const normalizeTab = (tab: string) => {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
return file.tab(tab)
|
||||
@@ -329,7 +333,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
id: "message.previous",
|
||||
title: language.t("command.message.previous"),
|
||||
description: language.t("command.message.previous.description"),
|
||||
keybind: "mod+alt+[",
|
||||
keybind: "mod+arrowup",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(-1),
|
||||
}),
|
||||
@@ -337,7 +341,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
id: "message.next",
|
||||
title: language.t("command.message.next"),
|
||||
description: language.t("command.message.next.description"),
|
||||
keybind: "mod+alt+]",
|
||||
keybind: "mod+arrowdown",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(1),
|
||||
}),
|
||||
|
||||
@@ -112,11 +112,4 @@ describe("persist localStorage resilience", () => {
|
||||
expect(result.endsWith(".dat")).toBeTrue()
|
||||
expect(/[:\\/]/.test(result)).toBeFalse()
|
||||
})
|
||||
|
||||
test("workspace storage treats slash variants as the same workspace", () => {
|
||||
const a = persistTesting.workspaceStorage("C:\\Users\\foo\\bar\\")
|
||||
const b = persistTesting.workspaceStorage("C:/Users/foo/bar")
|
||||
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Platform, usePlatform } from "@/context/platform"
|
||||
import { workspaceKey } from "@/utils/workspace"
|
||||
import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createResource, type Accessor } from "solid-js"
|
||||
@@ -210,9 +209,8 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) =>
|
||||
}
|
||||
|
||||
function workspaceStorage(dir: string) {
|
||||
const key = workspaceKey(dir)
|
||||
const head = (key.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const sum = checksum(key) ?? "0"
|
||||
const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const sum = checksum(dir) ?? "0"
|
||||
return `opencode.workspace.${head}.${sum}.dat`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export const workspaceKey = (dir: string) => {
|
||||
const value = dir.replaceAll("\\", "/")
|
||||
const drive = value.match(/^([A-Za-z]:)\/+$/)
|
||||
if (drive) return `${drive[1]}/`
|
||||
if (/^\/+$/i.test(value)) return "/"
|
||||
return value.replace(/\/+$/, "")
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
"open-directory-picker",
|
||||
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
|
||||
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
||||
title: opts?.title ?? "Choose a folder",
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ const cache = new Map<string, Store>()
|
||||
export function getStore(name = SETTINGS_STORE) {
|
||||
const cached = cache.get(name)
|
||||
if (cached) return cached
|
||||
const next = new Store({ name, fileExtension: "" })
|
||||
const next = new Store({ name })
|
||||
cache.set(name, next)
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
CREATE TABLE `event_sequence` (
|
||||
`aggregate_id` text PRIMARY KEY,
|
||||
`seq` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `event` (
|
||||
`id` text PRIMARY KEY,
|
||||
`aggregate_id` text NOT NULL,
|
||||
`seq` integer NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`data` text NOT NULL,
|
||||
CONSTRAINT `fk_event_aggregate_id_event_sequence_aggregate_id_fk` FOREIGN KEY (`aggregate_id`) REFERENCES `event_sequence`(`aggregate_id`) ON DELETE CASCADE
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,7 @@
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
@@ -92,7 +93,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
@@ -145,6 +146,7 @@
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.18.0",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
|
||||
@@ -21,14 +21,10 @@ const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
||||
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.js"),
|
||||
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
|
||||
path.join(dir, "src/provider/models-snapshot.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.d.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
|
||||
)
|
||||
console.log("Generated models-snapshot.js")
|
||||
console.log("Generated models-snapshot.ts")
|
||||
|
||||
// Load migrations from migration directories
|
||||
const migrationDirs = (
|
||||
|
||||
@@ -164,7 +164,7 @@ Still open and likely worth migrating:
|
||||
- [x] `Plugin`
|
||||
- [x] `ToolRegistry`
|
||||
- [ ] `Pty`
|
||||
- [x] `Worktree`
|
||||
- [ ] `Worktree`
|
||||
- [ ] `Bus`
|
||||
- [x] `Command`
|
||||
- [ ] `Config`
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } f
|
||||
export type AccountRow = (typeof AccountTable)["$inferSelect"]
|
||||
|
||||
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
|
||||
type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
|
||||
|
||||
const ACCOUNT_STATE_ID = 1
|
||||
|
||||
@@ -43,13 +42,13 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
|
||||
Effect.gen(function* () {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
const query = <A>(f: DbTransactionCallback<A>) =>
|
||||
const query = <A>(f: (db: DbClient) => A) =>
|
||||
Effect.try({
|
||||
try: () => Database.use(f),
|
||||
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||
})
|
||||
|
||||
const tx = <A>(f: DbTransactionCallback<A>) =>
|
||||
const tx = <A>(f: (db: DbClient) => A) =>
|
||||
Effect.try({
|
||||
try: () => Database.transaction(f),
|
||||
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||
|
||||
@@ -148,6 +148,7 @@ export namespace Agent {
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
todoread: "deny",
|
||||
todowrite: "deny",
|
||||
}),
|
||||
user,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import z from "zod"
|
||||
import type { ZodType } from "zod"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace BusEvent {
|
||||
const log = Log.create({ service: "event" })
|
||||
|
||||
export type Definition = ReturnType<typeof define>
|
||||
|
||||
const registry = new Map<string, Definition>()
|
||||
|
||||
@@ -14,7 +14,19 @@ import type { Argv } from "yargs"
|
||||
|
||||
type AgentMode = "all" | "primary" | "subagent"
|
||||
|
||||
const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"]
|
||||
const AVAILABLE_TOOLS = [
|
||||
"bash",
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"list",
|
||||
"glob",
|
||||
"grep",
|
||||
"webfetch",
|
||||
"task",
|
||||
"todowrite",
|
||||
"todoread",
|
||||
]
|
||||
|
||||
const AgentCreateCommand = cmd({
|
||||
command: "create",
|
||||
|
||||
@@ -28,9 +28,9 @@ import { Provider } from "../../provider/provider"
|
||||
import { Bus } from "../../bus"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { Git } from "@/git"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Process } from "@/util/process"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
@@ -257,7 +257,7 @@ export const GithubInstallCommand = cmd({
|
||||
}
|
||||
|
||||
// Get repo info
|
||||
const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
|
||||
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
|
||||
const parsed = parseGitHubRemote(info)
|
||||
if (!parsed) {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
@@ -496,20 +496,20 @@ export const GithubRunCommand = cmd({
|
||||
: "issue"
|
||||
: undefined
|
||||
const gitText = async (args: string[]) => {
|
||||
const result = await Git.run(args, { cwd: Instance.worktree })
|
||||
const result = await git(args, { cwd: Instance.worktree })
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result.text().trim()
|
||||
}
|
||||
const gitRun = async (args: string[]) => {
|
||||
const result = await Git.run(args, { cwd: Instance.worktree })
|
||||
const result = await git(args, { cwd: Instance.worktree })
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree })
|
||||
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
|
||||
const commitChanges = async (summary: string, actor?: string) => {
|
||||
const args = ["commit", "-m", summary]
|
||||
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
|
||||
@@ -869,6 +869,7 @@ export const GithubRunCommand = cmd({
|
||||
function subscribeSessionEvents() {
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
||||
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
||||
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
||||
@@ -889,7 +890,7 @@ export const GithubRunCommand = cmd({
|
||||
}
|
||||
|
||||
let text = ""
|
||||
Bus.subscribe(MessageV2.Event.PartUpdated, (evt) => {
|
||||
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
|
||||
if (evt.properties.part.sessionID !== session.id) return
|
||||
//if (evt.properties.part.messageID === messageID) return
|
||||
const part = evt.properties.part
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { Git } from "@/git"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Process } from "@/util/process"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
export const PrCommand = cmd({
|
||||
command: "pr <number>",
|
||||
@@ -67,9 +67,9 @@ export const PrCommand = cmd({
|
||||
const remoteName = forkOwner
|
||||
|
||||
// Check if remote already exists
|
||||
const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
|
||||
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
|
||||
if (!remotes.split("\n").includes(remoteName)) {
|
||||
await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
UI.println(`Added fork remote: ${remoteName}`)
|
||||
@@ -77,7 +77,7 @@ export const PrCommand = cmd({
|
||||
|
||||
// Set upstream to the fork so pushes go there
|
||||
const headRefName = prInfo.headRefName
|
||||
await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -710,7 +710,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
})
|
||||
})
|
||||
|
||||
sdk.event.on("session.deleted", (evt) => {
|
||||
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
|
||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||
route.navigate({ type: "home" })
|
||||
toast.show({
|
||||
@@ -720,7 +720,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
}
|
||||
})
|
||||
|
||||
sdk.event.on("session.error", (evt) => {
|
||||
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
|
||||
const error = evt.properties.error
|
||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||
const message = (() => {
|
||||
|
||||
@@ -673,6 +673,7 @@ export namespace Config {
|
||||
task: PermissionRule.optional(),
|
||||
external_directory: PermissionRule.optional(),
|
||||
todowrite: PermissionAction.optional(),
|
||||
todoread: PermissionAction.optional(),
|
||||
question: PermissionAction.optional(),
|
||||
webfetch: PermissionAction.optional(),
|
||||
websearch: PermissionAction.optional(),
|
||||
|
||||
@@ -22,11 +22,12 @@ export const WorktreeAdaptor: Adaptor = {
|
||||
},
|
||||
async create(info) {
|
||||
const config = Config.parse(info)
|
||||
await Worktree.createFromInfo({
|
||||
const bootstrap = await Worktree.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
branch: config.branch,
|
||||
})
|
||||
return bootstrap()
|
||||
},
|
||||
async remove(info) {
|
||||
const config = Config.parse(info)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { Git } from "@/git"
|
||||
import { git } from "@/util/git"
|
||||
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import fs from "fs"
|
||||
@@ -432,7 +432,7 @@ export namespace File {
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const diffOutput = (
|
||||
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
@@ -452,7 +452,7 @@ export namespace File {
|
||||
}
|
||||
|
||||
const untrackedOutput = (
|
||||
await Git.run(
|
||||
await git(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
@@ -485,7 +485,7 @@ export namespace File {
|
||||
}
|
||||
|
||||
const deletedOutput = (
|
||||
await Git.run(
|
||||
await git(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
@@ -576,17 +576,17 @@ export namespace File {
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
let diff = (
|
||||
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||
).text()
|
||||
if (!diff.trim()) {
|
||||
diff = (
|
||||
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
|
||||
@@ -10,8 +10,8 @@ import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Git } from "@/git"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { git } from "@/util/git"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Config } from "../config/config"
|
||||
import { FileIgnore } from "./ignore"
|
||||
@@ -130,7 +130,7 @@ export namespace FileWatcher {
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
const result = yield* Effect.promise(() =>
|
||||
Git.run(["rev-parse", "--git-dir"], {
|
||||
git(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.project.worktree,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
|
||||
export namespace Git {
|
||||
const cfg = [
|
||||
"--no-optional-locks",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
] as const
|
||||
|
||||
const out = (result: { text(): string }) => result.text().trim()
|
||||
const nuls = (text: string) => text.split("\0").filter(Boolean)
|
||||
const fail = (err: unknown) =>
|
||||
({
|
||||
exitCode: 1,
|
||||
text: () => "",
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
|
||||
}) satisfies Result
|
||||
|
||||
export type Kind = "added" | "deleted" | "modified"
|
||||
|
||||
export type Base = {
|
||||
readonly name: string
|
||||
readonly ref: string
|
||||
}
|
||||
|
||||
export type Item = {
|
||||
readonly file: string
|
||||
readonly code: string
|
||||
readonly status: Kind
|
||||
}
|
||||
|
||||
export type Stat = {
|
||||
readonly file: string
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
readonly exitCode: number
|
||||
readonly text: () => string
|
||||
readonly stdout: Buffer
|
||||
readonly stderr: Buffer
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
readonly cwd: string
|
||||
readonly env?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly run: (args: string[], opts: Options) => Effect.Effect<Result>
|
||||
readonly branch: (cwd: string) => Effect.Effect<string | undefined>
|
||||
readonly prefix: (cwd: string) => Effect.Effect<string>
|
||||
readonly defaultBranch: (cwd: string) => Effect.Effect<Base | undefined>
|
||||
readonly hasHead: (cwd: string) => Effect.Effect<boolean>
|
||||
readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect<string | undefined>
|
||||
readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect<string>
|
||||
readonly status: (cwd: string) => Effect.Effect<Item[]>
|
||||
readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
|
||||
readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
|
||||
}
|
||||
|
||||
const kind = (code: string): Kind => {
|
||||
if (code === "??") return "added"
|
||||
if (code.includes("U")) return "modified"
|
||||
if (code.includes("A") && !code.includes("D")) return "added"
|
||||
if (code.includes("D") && !code.includes("A")) return "deleted"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Git") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
|
||||
const run = Effect.fn("Git.run")(
|
||||
function* (args: string[], opts: Options) {
|
||||
const proc = ChildProcess.make("git", [...cfg, ...args], {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [stdout, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
return {
|
||||
exitCode: yield* handle.exitCode,
|
||||
text: () => stdout,
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.from(stderr),
|
||||
} satisfies Result
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch((err) => Effect.succeed(fail(err))),
|
||||
)
|
||||
|
||||
const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) {
|
||||
return (yield* run(args, opts)).text()
|
||||
})
|
||||
|
||||
const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) {
|
||||
return (yield* text(args, opts))
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
const refs = Effect.fnUntraced(function* (cwd: string) {
|
||||
return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
|
||||
})
|
||||
|
||||
const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) {
|
||||
const result = yield* run(["config", "init.defaultBranch"], { cwd })
|
||||
const name = out(result)
|
||||
if (!name || !list.includes(name)) return
|
||||
return { name, ref: name } satisfies Base
|
||||
})
|
||||
|
||||
const primary = Effect.fnUntraced(function* (cwd: string) {
|
||||
const list = yield* lines(["remote"], { cwd })
|
||||
if (list.includes("origin")) return "origin"
|
||||
if (list.length === 1) return list[0]
|
||||
if (list.includes("upstream")) return "upstream"
|
||||
return list[0]
|
||||
})
|
||||
|
||||
const branch = Effect.fn("Git.branch")(function* (cwd: string) {
|
||||
const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd })
|
||||
if (result.exitCode !== 0) return
|
||||
const text = out(result)
|
||||
return text || undefined
|
||||
})
|
||||
|
||||
const prefix = Effect.fn("Git.prefix")(function* (cwd: string) {
|
||||
const result = yield* run(["rev-parse", "--show-prefix"], { cwd })
|
||||
if (result.exitCode !== 0) return ""
|
||||
return out(result)
|
||||
})
|
||||
|
||||
const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) {
|
||||
const remote = yield* primary(cwd)
|
||||
if (remote) {
|
||||
const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd })
|
||||
if (head.exitCode === 0) {
|
||||
const ref = out(head).replace(/^refs\/remotes\//, "")
|
||||
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
|
||||
if (name) return { name, ref } satisfies Base
|
||||
}
|
||||
}
|
||||
|
||||
const list = yield* refs(cwd)
|
||||
const next = yield* configured(cwd, list)
|
||||
if (next) return next
|
||||
if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
|
||||
if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
|
||||
})
|
||||
|
||||
const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) {
|
||||
const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd })
|
||||
return result.exitCode === 0
|
||||
})
|
||||
|
||||
const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") {
|
||||
const result = yield* run(["merge-base", base, head], { cwd })
|
||||
if (result.exitCode !== 0) return
|
||||
const text = out(result)
|
||||
return text || undefined
|
||||
})
|
||||
|
||||
const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") {
|
||||
const target = prefix ? `${prefix}${file}` : file
|
||||
const result = yield* run(["show", `${ref}:${target}`], { cwd })
|
||||
if (result.exitCode !== 0) return ""
|
||||
if (result.stdout.includes(0)) return ""
|
||||
return result.text()
|
||||
})
|
||||
|
||||
const status = Effect.fn("Git.status")(function* (cwd: string) {
|
||||
return nuls(
|
||||
yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], {
|
||||
cwd,
|
||||
}),
|
||||
).flatMap((item) => {
|
||||
const file = item.slice(3)
|
||||
if (!file) return []
|
||||
const code = item.slice(0, 2)
|
||||
return [{ file, code, status: kind(code) } satisfies Item]
|
||||
})
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) {
|
||||
const list = nuls(
|
||||
yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
|
||||
)
|
||||
return list.flatMap((code, idx) => {
|
||||
if (idx % 2 !== 0) return []
|
||||
const file = list[idx + 1]
|
||||
if (!code || !file) return []
|
||||
return [{ file, code, status: kind(code) } satisfies Item]
|
||||
})
|
||||
})
|
||||
|
||||
const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) {
|
||||
return nuls(
|
||||
yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
|
||||
).flatMap((item) => {
|
||||
const a = item.indexOf("\t")
|
||||
const b = item.indexOf("\t", a + 1)
|
||||
if (a === -1 || b === -1) return []
|
||||
const file = item.slice(b + 1)
|
||||
if (!file) return []
|
||||
const adds = item.slice(0, a)
|
||||
const dels = item.slice(a + 1, b)
|
||||
const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
|
||||
const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
|
||||
return [
|
||||
{
|
||||
file,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
} satisfies Stat,
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
run,
|
||||
branch,
|
||||
prefix,
|
||||
defaultBranch,
|
||||
hasHead,
|
||||
mergeBase,
|
||||
show,
|
||||
status,
|
||||
diff,
|
||||
stats,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export function run(args: string[], opts: Options) {
|
||||
return runPromise((git) => git.run(args, opts))
|
||||
}
|
||||
|
||||
export function branch(cwd: string) {
|
||||
return runPromise((git) => git.branch(cwd))
|
||||
}
|
||||
|
||||
export function prefix(cwd: string) {
|
||||
return runPromise((git) => git.prefix(cwd))
|
||||
}
|
||||
|
||||
export function defaultBranch(cwd: string) {
|
||||
return runPromise((git) => git.defaultBranch(cwd))
|
||||
}
|
||||
|
||||
export function hasHead(cwd: string) {
|
||||
return runPromise((git) => git.hasHead(cwd))
|
||||
}
|
||||
|
||||
export function mergeBase(cwd: string, base: string, head?: string) {
|
||||
return runPromise((git) => git.mergeBase(cwd, base, head))
|
||||
}
|
||||
|
||||
export function show(cwd: string, ref: string, file: string, prefix?: string) {
|
||||
return runPromise((git) => git.show(cwd, ref, file, prefix))
|
||||
}
|
||||
|
||||
export function status(cwd: string) {
|
||||
return runPromise((git) => git.status(cwd))
|
||||
}
|
||||
|
||||
export function diff(cwd: string, ref: string) {
|
||||
return runPromise((git) => git.diff(cwd, ref))
|
||||
}
|
||||
|
||||
export function stats(cwd: string, ref: string) {
|
||||
return runPromise((git) => git.stats(cwd, ref))
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { randomBytes } from "crypto"
|
||||
|
||||
export namespace Identifier {
|
||||
const prefixes = {
|
||||
event: "evt",
|
||||
session: "ses",
|
||||
message: "msg",
|
||||
permission: "per",
|
||||
|
||||
@@ -177,12 +177,6 @@ export namespace LSP {
|
||||
|
||||
async function getClients(file: string) {
|
||||
const s = await state()
|
||||
|
||||
// Only spawn LSP clients for files within the instance directory
|
||||
if (!Instance.containsPath(file)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const extension = path.parse(file).ext || file
|
||||
const result: LSPClient.Info[] = []
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import { Config } from "../config/config"
|
||||
import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Server } from "../server/server"
|
||||
import { BunProc } from "../bun"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { CodexAuthPlugin } from "./openai/codex"
|
||||
import { Session } from "../session"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { CopilotAuthPlugin } from "./copilot"
|
||||
@@ -57,8 +58,6 @@ export namespace Plugin {
|
||||
const hooks: Hooks[] = []
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
const { Server } = await import("../server/server")
|
||||
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
directory: ctx.directory,
|
||||
|
||||
+20
-6
@@ -1,11 +1,12 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { Log } from "../util/log"
|
||||
import { Installation } from "../installation"
|
||||
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { Log } from "../../util/log"
|
||||
import { Installation } from "../../installation"
|
||||
import { OAUTH_DUMMY_KEY } from "../../auth"
|
||||
import os from "os"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createWebSocketFetch } from "./websocket"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -351,12 +352,18 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
|
||||
}
|
||||
|
||||
export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
const ws = createWebSocketFetch()
|
||||
return {
|
||||
auth: {
|
||||
provider: "openai",
|
||||
async loader(getAuth, provider) {
|
||||
const auth = await getAuth()
|
||||
if (auth.type !== "oauth") return {}
|
||||
if (auth.type !== "oauth")
|
||||
return {
|
||||
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
return ws(requestInput, init)
|
||||
},
|
||||
}
|
||||
|
||||
// Filter models to only allowed Codex models for OAuth
|
||||
const allowedModels = new Set([
|
||||
@@ -491,7 +498,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
? new URL(CODEX_API_ENDPOINT)
|
||||
: parsed
|
||||
|
||||
return fetch(url, {
|
||||
return ws(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
@@ -622,7 +629,14 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
if (input.model.providerID !== "openai") return
|
||||
output.headers.originator = "opencode"
|
||||
output.headers["User-Agent"] = `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`
|
||||
output.headers.session_id = input.sessionID
|
||||
|
||||
// TODO: this is kinda hacky, we need to move the transport creation to a later point so it can accept more arguments rather than just relying on whatever is passed to fetch...
|
||||
// distinguish between title gen and actual chat session
|
||||
if (!input.small) {
|
||||
output.headers.session_id = input.sessionID
|
||||
} else {
|
||||
output.headers.session_id = input.sessionID + "_title"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import WebSocket from "ws"
|
||||
import { Log } from "@/util/log"
|
||||
|
||||
const log = Log.create({ service: "plugin.openai.websocket" })
|
||||
|
||||
export interface CreateWebSocketFetchOptions {
|
||||
/**
|
||||
* WebSocket endpoint URL.
|
||||
* @default 'wss://api.openai.com/v1/responses'
|
||||
*/
|
||||
url?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a `fetch` function that routes OpenAI Responses API streaming
|
||||
* requests through a persistent WebSocket connection instead of HTTP.
|
||||
*
|
||||
* Non-streaming requests and requests to other endpoints are passed
|
||||
* through to the standard `fetch`.
|
||||
*
|
||||
* The connection is created lazily on the first streaming request and
|
||||
* reused for subsequent ones, which is the main source of latency
|
||||
* savings in multi-step tool-calling workflows.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createOpenAI } from '@ai-sdk/openai';
|
||||
* import { createWebSocketFetch } from 'ai-sdk-openai-websocket-fetch';
|
||||
*
|
||||
* const wsFetch = createWebSocketFetch();
|
||||
* const openai = createOpenAI({ fetch: wsFetch });
|
||||
*
|
||||
* const result = streamText({
|
||||
* model: openai('gpt-4.1-mini'),
|
||||
* prompt: 'Hello!',
|
||||
* onFinish: () => wsFetch.close(),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
|
||||
let ws: WebSocket | null = null
|
||||
let connecting: Promise<WebSocket> | null = null
|
||||
let busy = false
|
||||
|
||||
function getConnection(url: string, headers: Record<string, string>): Promise<WebSocket> {
|
||||
if (ws?.readyState === WebSocket.OPEN && !busy) {
|
||||
log.debug("reusing websocket", { url })
|
||||
return Promise.resolve(ws)
|
||||
}
|
||||
if (connecting && !busy) return connecting
|
||||
|
||||
connecting = new Promise<WebSocket>((resolve, reject) => {
|
||||
log.debug("connecting websocket", {
|
||||
url,
|
||||
headers: Object.keys(headers).sort().join(","),
|
||||
})
|
||||
const socket = new WebSocket(url, { headers })
|
||||
|
||||
socket.on("open", () => {
|
||||
ws = socket
|
||||
connecting = null
|
||||
log.debug("websocket connected", { url })
|
||||
resolve(socket)
|
||||
})
|
||||
|
||||
socket.on("error", (err) => {
|
||||
log.debug("websocket connect error", {
|
||||
url,
|
||||
error: err.message,
|
||||
})
|
||||
if (connecting) {
|
||||
connecting = null
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("close", () => {
|
||||
log.debug("websocket closed", { url })
|
||||
if (ws === socket) ws = null
|
||||
})
|
||||
})
|
||||
|
||||
return connecting
|
||||
}
|
||||
|
||||
async function websocketFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = input instanceof URL ? input.toString() : typeof input === "string" ? input : input.url
|
||||
|
||||
if (init?.method !== "POST" || !url.endsWith("/responses")) {
|
||||
return globalThis.fetch(input, init)
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
body = JSON.parse(typeof init.body === "string" ? init.body : "")
|
||||
} catch {
|
||||
return globalThis.fetch(input, init)
|
||||
}
|
||||
|
||||
if (!body.stream) {
|
||||
return globalThis.fetch(input, init)
|
||||
}
|
||||
|
||||
const wsUrl = getWebSocketURL(url, options?.url)
|
||||
const headers = getWebSocketHeaders(init.headers)
|
||||
|
||||
log.debug("intercepting responses request", {
|
||||
url,
|
||||
wsUrl,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
const connection = await getConnection(wsUrl, headers)
|
||||
busy = true
|
||||
|
||||
const { stream: _, ...requestBody } = body
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const responseStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
function cleanup() {
|
||||
connection.off("message", onMessage)
|
||||
connection.off("error", onError)
|
||||
connection.off("close", onClose)
|
||||
busy = false
|
||||
}
|
||||
|
||||
function onMessage(data: WebSocket.RawData) {
|
||||
const text = data.toString()
|
||||
log.debug("websocket event", { event: pretty(text), url: wsUrl })
|
||||
controller.enqueue(encoder.encode(`data: ${text}\n\n`))
|
||||
|
||||
try {
|
||||
const event = JSON.parse(text)
|
||||
if (event.type === "response.completed" || event.type === "error") {
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
|
||||
cleanup()
|
||||
controller.close()
|
||||
}
|
||||
} catch {
|
||||
// non-JSON frame, continue
|
||||
}
|
||||
}
|
||||
|
||||
function onError(err: Error) {
|
||||
log.debug("websocket stream error", { url: wsUrl, error: err.message })
|
||||
cleanup()
|
||||
controller.error(err)
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
log.debug("websocket stream close", { url: wsUrl })
|
||||
cleanup()
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
}
|
||||
|
||||
connection.on("message", onMessage)
|
||||
connection.on("error", onError)
|
||||
connection.on("close", onClose)
|
||||
|
||||
if (init?.signal) {
|
||||
if (init.signal.aborted) {
|
||||
cleanup()
|
||||
controller.error(init.signal.reason ?? new DOMException("Aborted", "AbortError"))
|
||||
return
|
||||
}
|
||||
init.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
cleanup()
|
||||
try {
|
||||
controller.error(init!.signal!.reason ?? new DOMException("Aborted", "AbortError"))
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}
|
||||
|
||||
connection.send(JSON.stringify({ type: "response.create", ...requestBody }))
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(responseStream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
|
||||
return Object.assign(websocketFetch, {
|
||||
/** Close the underlying WebSocket connection. */
|
||||
close() {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
if (!headers) return result
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((v, k) => {
|
||||
result[k.toLowerCase()] = v
|
||||
})
|
||||
} else if (Array.isArray(headers)) {
|
||||
for (const [k, v] of headers) {
|
||||
result[k.toLowerCase()] = v
|
||||
}
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (v != null) result[k.toLowerCase()] = v
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function getWebSocketHeaders(headers: HeadersInit | undefined) {
|
||||
const result = normalizeHeaders(headers)
|
||||
delete result["content-length"]
|
||||
result["openai-beta"] ??= "responses_websockets=2026-02-06"
|
||||
return result
|
||||
}
|
||||
|
||||
function getWebSocketURL(url: string, fallback?: string) {
|
||||
if (fallback) return fallback
|
||||
|
||||
const parsed = new URL(url)
|
||||
parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:"
|
||||
return parsed.toString()
|
||||
}
|
||||
|
||||
function pretty(text: string) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
@@ -1,111 +1,17 @@
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import path from "path"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Git } from "@/git"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Log } from "@/util/log"
|
||||
import { git } from "@/util/git"
|
||||
import { Instance } from "./instance"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Vcs {
|
||||
const log = Log.create({ service: "vcs" })
|
||||
|
||||
const count = (text: string) => {
|
||||
if (!text) return 0
|
||||
if (!text.endsWith("\n")) return text.split("\n").length
|
||||
return text.slice(0, -1).split("\n").length
|
||||
}
|
||||
|
||||
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
|
||||
const full = path.join(cwd, file)
|
||||
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
|
||||
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||
if (Buffer.from(buf).includes(0)) return ""
|
||||
return Buffer.from(buf).toString("utf8")
|
||||
})
|
||||
|
||||
const nums = (list: Git.Stat[]) =>
|
||||
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
|
||||
|
||||
const merge = (...lists: Git.Item[][]) => {
|
||||
const out = new Map<string, Git.Item>()
|
||||
lists.flat().forEach((item) => {
|
||||
if (!out.has(item.file)) out.set(item.file, item)
|
||||
})
|
||||
return [...out.values()]
|
||||
}
|
||||
|
||||
const files = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
list: Git.Item[],
|
||||
map: Map<string, { additions: number; deletions: number }>,
|
||||
) {
|
||||
const base = ref ? yield* git.prefix(cwd) : ""
|
||||
const next = yield* Effect.forEach(
|
||||
list,
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
|
||||
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
|
||||
const stat = map.get(item.file)
|
||||
return {
|
||||
file: item.file,
|
||||
before,
|
||||
after,
|
||||
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
|
||||
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
|
||||
status: item.status,
|
||||
} satisfies Snapshot.FileDiff
|
||||
}),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
return next.toSorted((a, b) => a.file.localeCompare(b.file))
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
) {
|
||||
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
|
||||
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
|
||||
return yield* files(fs, git, cwd, ref, list, nums(stats))
|
||||
})
|
||||
|
||||
const compare = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string,
|
||||
) {
|
||||
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
|
||||
concurrency: 3,
|
||||
})
|
||||
return yield* files(
|
||||
fs,
|
||||
git,
|
||||
cwd,
|
||||
ref,
|
||||
merge(
|
||||
list,
|
||||
extra.filter((item) => item.code === "??"),
|
||||
),
|
||||
nums(stats),
|
||||
)
|
||||
})
|
||||
|
||||
export const Mode = z.enum(["git", "branch"])
|
||||
export type Mode = z.infer<typeof Mode>
|
||||
|
||||
export const Event = {
|
||||
BranchUpdated: BusEvent.define(
|
||||
"vcs.branch.updated",
|
||||
@@ -117,8 +23,7 @@ export namespace Vcs {
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
branch: z.string().optional(),
|
||||
default_branch: z.string().optional(),
|
||||
branch: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "VcsInfo",
|
||||
@@ -128,35 +33,37 @@ export namespace Vcs {
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly branch: () => Effect.Effect<string | undefined>
|
||||
readonly defaultBranch: () => Effect.Effect<string | undefined>
|
||||
readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]>
|
||||
}
|
||||
|
||||
interface State {
|
||||
current: string | undefined
|
||||
root: Git.Base | undefined
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service> = Layer.effect(
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Vcs.state")((ctx) =>
|
||||
Effect.gen(function* () {
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return { current: undefined, root: undefined }
|
||||
return { current: undefined }
|
||||
}
|
||||
|
||||
const get = () => Effect.runPromise(git.branch(ctx.directory))
|
||||
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
const value = { current, root }
|
||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||
const getCurrentBranch = async () => {
|
||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: ctx.worktree,
|
||||
})
|
||||
if (result.exitCode !== 0) return undefined
|
||||
const text = result.text().trim()
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
const value = {
|
||||
current: yield* Effect.promise(() => getCurrentBranch()),
|
||||
}
|
||||
log.info("initialized", { branch: value.current })
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
@@ -164,11 +71,12 @@ export namespace Vcs {
|
||||
FileWatcher.Event.Updated,
|
||||
Instance.bind(async (evt) => {
|
||||
if (!evt.properties.file.endsWith("HEAD")) return
|
||||
const next = await get()
|
||||
if (next === value.current) return
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||
const next = await getCurrentBranch()
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -187,34 +95,11 @@ export namespace Vcs {
|
||||
branch: Effect.fn("Vcs.branch")(function* () {
|
||||
return yield* InstanceState.use(state, (x) => x.current)
|
||||
}),
|
||||
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
|
||||
return yield* InstanceState.use(state, (x) => x.root?.name)
|
||||
}),
|
||||
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
|
||||
const value = yield* InstanceState.get(state)
|
||||
if (Instance.project.vcs !== "git") return []
|
||||
if (mode === "git") {
|
||||
return yield* track(
|
||||
fs,
|
||||
git,
|
||||
Instance.directory,
|
||||
(yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
if (!value.root) return []
|
||||
if (value.current && value.current === value.root.name) return []
|
||||
const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
|
||||
if (!ref) return []
|
||||
return yield* compare(fs, git, Instance.directory, ref)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
@@ -223,12 +108,4 @@ export namespace Vcs {
|
||||
export function branch() {
|
||||
return runPromise((svc) => svc.branch())
|
||||
}
|
||||
|
||||
export function defaultBranch() {
|
||||
return runPromise((svc) => svc.defaultBranch())
|
||||
}
|
||||
|
||||
export function diff(mode: Mode) {
|
||||
return runPromise((svc) => svc.diff(mode))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export namespace ModelsDev {
|
||||
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
|
||||
if (result) return result
|
||||
// @ts-ignore
|
||||
const snapshot = await import("./models-snapshot.js")
|
||||
const snapshot = await import("./models-snapshot")
|
||||
.then((m) => m.snapshot as Record<string, unknown>)
|
||||
.catch(() => undefined)
|
||||
if (snapshot) return snapshot
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import z from "zod"
|
||||
import sessionProjectors from "../session/projectors"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Session } from "@/session"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
|
||||
export function initProjectors() {
|
||||
SyncEvent.init({
|
||||
projectors: sessionProjectors,
|
||||
convertEvent: (type, data) => {
|
||||
if (type === "session.updated") {
|
||||
const id = (data as z.infer<typeof Session.Event.Updated.schema>).sessionID
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
|
||||
if (!row) return data
|
||||
|
||||
return {
|
||||
sessionID: id,
|
||||
info: Session.fromRow(row),
|
||||
}
|
||||
}
|
||||
return data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
initProjectors()
|
||||
@@ -6,6 +6,7 @@ import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { AsyncQueue } from "../../util/queue"
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -52,6 +53,13 @@ export const EventRoutes = lazy(() =>
|
||||
)
|
||||
}, 10_000)
|
||||
|
||||
const unsub = Bus.subscribeAll((event) => {
|
||||
q.push(JSON.stringify(event))
|
||||
if (event.type === Bus.InstanceDisposed.type) {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
|
||||
const stop = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
@@ -61,13 +69,6 @@ export const EventRoutes = lazy(() =>
|
||||
log.info("event disconnected")
|
||||
}
|
||||
|
||||
const unsub = Bus.subscribeAll((event) => {
|
||||
q.push(JSON.stringify(event))
|
||||
if (event.type === Bus.InstanceDisposed.type) {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
|
||||
stream.onAbort(stop)
|
||||
|
||||
try {
|
||||
|
||||
@@ -108,7 +108,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.CreateInput.optional()),
|
||||
validator("json", Worktree.create.schema),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const worktree = await Worktree.create(body)
|
||||
@@ -155,7 +155,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.RemoveInput),
|
||||
validator("json", Worktree.remove.schema),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Worktree.remove(body)
|
||||
@@ -181,7 +181,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.ResetInput),
|
||||
validator("json", Worktree.reset.schema),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Worktree.reset(body)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Hono, type Context } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import z from "zod"
|
||||
import { Bus } from "../../bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
import { Instance } from "../../project/instance"
|
||||
@@ -17,56 +17,6 @@ const log = Log.create({ service: "server" })
|
||||
|
||||
export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
|
||||
|
||||
async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
|
||||
return streamSSE(c, async (stream) => {
|
||||
const q = new AsyncQueue<string | null>()
|
||||
let done = false
|
||||
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||
const heartbeat = setInterval(() => {
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, 10_000)
|
||||
|
||||
const stop = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearInterval(heartbeat)
|
||||
unsub()
|
||||
q.push(null)
|
||||
log.info("global event disconnected")
|
||||
}
|
||||
|
||||
const unsub = subscribe(q)
|
||||
|
||||
stream.onAbort(stop)
|
||||
|
||||
try {
|
||||
for await (const data of q) {
|
||||
if (data === null) return
|
||||
await stream.writeSSE({ data })
|
||||
}
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const GlobalRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
@@ -120,58 +70,55 @@ export const GlobalRoutes = lazy(() =>
|
||||
log.info("global event connected")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamSSE(c, async (stream) => {
|
||||
const q = new AsyncQueue<string | null>()
|
||||
let done = false
|
||||
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||
const heartbeat = setInterval(() => {
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, 10_000)
|
||||
|
||||
return streamEvents(c, (q) => {
|
||||
async function handler(event: any) {
|
||||
q.push(JSON.stringify(event))
|
||||
}
|
||||
GlobalBus.on("event", handler)
|
||||
return () => GlobalBus.off("event", handler)
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/sync-event",
|
||||
describeRoute({
|
||||
summary: "Subscribe to global sync events",
|
||||
description: "Get global sync events",
|
||||
operationId: "global.sync-event.subscribe",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
payload: SyncEvent.payloads(),
|
||||
})
|
||||
.meta({
|
||||
ref: "SyncEvent",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("global sync event connected")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamEvents(c, (q) => {
|
||||
return SyncEvent.subscribeAll(({ def, event }) => {
|
||||
// TODO: don't pass def, just pass the type (and it should
|
||||
// be versioned)
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
...event,
|
||||
type: SyncEvent.versionedType(def.type, def.version),
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const stop = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearInterval(heartbeat)
|
||||
GlobalBus.off("event", handler)
|
||||
q.push(null)
|
||||
log.info("event disconnected")
|
||||
}
|
||||
|
||||
stream.onAbort(stop)
|
||||
|
||||
try {
|
||||
for await (const data of q) {
|
||||
if (data === null) return
|
||||
await stream.writeSSE({ data })
|
||||
}
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -281,14 +281,14 @@ export const SessionRoutes = lazy(() =>
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const updates = c.req.valid("json")
|
||||
|
||||
let session = await Session.get(sessionID)
|
||||
if (updates.title !== undefined) {
|
||||
await Session.setTitle({ sessionID, title: updates.title })
|
||||
session = await Session.setTitle({ sessionID, title: updates.title })
|
||||
}
|
||||
if (updates.time?.archived !== undefined) {
|
||||
await Session.setArchived({ sessionID, time: updates.time.archived })
|
||||
session = await Session.setArchived({ sessionID, time: updates.time.archived })
|
||||
}
|
||||
|
||||
const session = await Session.get(sessionID)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { Log } from "../util/log"
|
||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
@@ -39,22 +38,15 @@ import { websocket } from "hono/bun"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import { errors } from "./error"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { QuestionRoutes } from "./routes/question"
|
||||
import { PermissionRoutes } from "./routes/permission"
|
||||
import { GlobalRoutes } from "./routes/global"
|
||||
import { MDNS } from "./mdns"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { initProjectors } from "./projectors"
|
||||
|
||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
||||
const csp = (hash = "") =>
|
||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
||||
|
||||
initProjectors()
|
||||
|
||||
export namespace Server {
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -338,40 +330,12 @@ export namespace Server {
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
|
||||
const branch = await Vcs.branch()
|
||||
return c.json({
|
||||
branch,
|
||||
default_branch,
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/vcs/diff",
|
||||
describeRoute({
|
||||
summary: "Get VCS diff",
|
||||
description: "Retrieve the current git diff for the working tree or against the default branch.",
|
||||
operationId: "vcs.diff",
|
||||
responses: {
|
||||
200: {
|
||||
description: "VCS diff",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Snapshot.FileDiff.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
mode: Vcs.Mode,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
return c.json(await Vcs.diff(c.req.valid("query").mode))
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/command",
|
||||
describeRoute({
|
||||
@@ -542,13 +506,10 @@ export namespace Server {
|
||||
host: "app.opencode.ai",
|
||||
},
|
||||
})
|
||||
const match = response.headers.get("content-type")?.includes("text/html")
|
||||
? (await response.clone().text()).match(
|
||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||
)
|
||||
: undefined
|
||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||
response.headers.set("Content-Security-Policy", csp(hash))
|
||||
response.headers.set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
|
||||
)
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { fn } from "@/util/fn"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Config } from "@/config/config"
|
||||
import { NotFoundError } from "@/storage/db"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
||||
@@ -61,11 +60,7 @@ export namespace SessionCompaction {
|
||||
const config = await Config.get()
|
||||
if (config.compaction?.prune === false) return
|
||||
log.info("pruning")
|
||||
const msgs = await Session.messages({ sessionID: input.sessionID }).catch((err) => {
|
||||
if (NotFoundError.isInstance(err)) return undefined
|
||||
throw err
|
||||
})
|
||||
if (!msgs) return
|
||||
const msgs = await Session.messages({ sessionID: input.sessionID })
|
||||
let total = 0
|
||||
let pruned = 0
|
||||
const toPrune = []
|
||||
|
||||
@@ -9,14 +9,12 @@ import { Config } from "../config/config"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Installation } from "../installation"
|
||||
|
||||
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
|
||||
import { SyncEvent } from "../sync"
|
||||
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
|
||||
import type { SQL } from "../storage/db"
|
||||
import { SessionTable } from "./session.sql"
|
||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
import { updateSchema } from "../util/update-schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Instance } from "../project/instance"
|
||||
import { SessionPrompt } from "./prompt"
|
||||
@@ -184,40 +182,24 @@ export namespace Session {
|
||||
export type GlobalInfo = z.output<typeof GlobalInfo>
|
||||
|
||||
export const Event = {
|
||||
Created: SyncEvent.define({
|
||||
type: "session.created",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
Created: BusEvent.define(
|
||||
"session.created",
|
||||
z.object({
|
||||
info: Info,
|
||||
}),
|
||||
}),
|
||||
Updated: SyncEvent.define({
|
||||
type: "session.updated",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: updateSchema(Info).extend({
|
||||
share: updateSchema(Info.shape.share.unwrap()).optional(),
|
||||
time: updateSchema(Info.shape.time).optional(),
|
||||
}),
|
||||
}),
|
||||
busSchema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
),
|
||||
Updated: BusEvent.define(
|
||||
"session.updated",
|
||||
z.object({
|
||||
info: Info,
|
||||
}),
|
||||
}),
|
||||
Deleted: SyncEvent.define({
|
||||
type: "session.deleted",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
),
|
||||
Deleted: BusEvent.define(
|
||||
"session.deleted",
|
||||
z.object({
|
||||
info: Info,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
Diff: BusEvent.define(
|
||||
"session.diff",
|
||||
z.object({
|
||||
@@ -298,8 +280,18 @@ export namespace Session {
|
||||
)
|
||||
|
||||
export const touch = fn(SessionID.zod, async (sessionID) => {
|
||||
const time = Date.now()
|
||||
SyncEvent.run(Event.Updated, { sessionID, info: { time: { updated: time } } })
|
||||
const now = Date.now()
|
||||
Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({ time_updated: now })
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
})
|
||||
})
|
||||
|
||||
export async function createNext(input: {
|
||||
@@ -326,25 +318,22 @@ export namespace Session {
|
||||
},
|
||||
}
|
||||
log.info("created", result)
|
||||
|
||||
SyncEvent.run(Event.Created, { sessionID: result.id, info: result })
|
||||
|
||||
Database.use((db) => {
|
||||
db.insert(SessionTable).values(toRow(result)).run()
|
||||
Database.effect(() =>
|
||||
Bus.publish(Event.Created, {
|
||||
info: result,
|
||||
}),
|
||||
)
|
||||
})
|
||||
const cfg = await Config.get()
|
||||
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) {
|
||||
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto"))
|
||||
share(result.id).catch(() => {
|
||||
// Silently ignore sharing errors during session creation
|
||||
})
|
||||
}
|
||||
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
// This only exist for backwards compatibility. We should not be
|
||||
// manually publishing this event; it is a sync event now
|
||||
Bus.publish(Event.Updated, {
|
||||
sessionID: result.id,
|
||||
info: result,
|
||||
})
|
||||
}
|
||||
|
||||
Bus.publish(Event.Updated, {
|
||||
info: result,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -368,9 +357,12 @@ export namespace Session {
|
||||
}
|
||||
const { ShareNext } = await import("@/share/share-next")
|
||||
const share = await ShareNext.create(id)
|
||||
|
||||
SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: share.url } } })
|
||||
|
||||
Database.use((db) => {
|
||||
const row = db.update(SessionTable).set({ share_url: share.url }).where(eq(SessionTable.id, id)).returning().get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
})
|
||||
return share
|
||||
})
|
||||
|
||||
@@ -378,8 +370,12 @@ export namespace Session {
|
||||
// Use ShareNext to remove the share (same as share function uses ShareNext to create)
|
||||
const { ShareNext } = await import("@/share/share-next")
|
||||
await ShareNext.remove(id)
|
||||
|
||||
SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } })
|
||||
Database.use((db) => {
|
||||
const row = db.update(SessionTable).set({ share_url: null }).where(eq(SessionTable.id, id)).returning().get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
})
|
||||
})
|
||||
|
||||
export const setTitle = fn(
|
||||
@@ -388,7 +384,18 @@ export namespace Session {
|
||||
title: z.string(),
|
||||
}),
|
||||
async (input) => {
|
||||
SyncEvent.run(Event.Updated, { sessionID: input.sessionID, info: { title: input.title } })
|
||||
return Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({ title: input.title })
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
return info
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -398,7 +405,18 @@ export namespace Session {
|
||||
time: z.number().optional(),
|
||||
}),
|
||||
async (input) => {
|
||||
SyncEvent.run(Event.Updated, { sessionID: input.sessionID, info: { time: { archived: input.time } } })
|
||||
return Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({ time_archived: input.time })
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
return info
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -408,9 +426,17 @@ export namespace Session {
|
||||
permission: Permission.Ruleset,
|
||||
}),
|
||||
async (input) => {
|
||||
SyncEvent.run(Event.Updated, {
|
||||
sessionID: input.sessionID,
|
||||
info: { permission: input.permission, time: { updated: Date.now() } },
|
||||
return Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({ permission: input.permission, time_updated: Date.now() })
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
return info
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -422,24 +448,42 @@ export namespace Session {
|
||||
summary: Info.shape.summary,
|
||||
}),
|
||||
async (input) => {
|
||||
SyncEvent.run(Event.Updated, {
|
||||
sessionID: input.sessionID,
|
||||
info: {
|
||||
summary: input.summary,
|
||||
time: { updated: Date.now() },
|
||||
revert: input.revert,
|
||||
},
|
||||
return Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
revert: input.revert ?? null,
|
||||
summary_additions: input.summary?.additions,
|
||||
summary_deletions: input.summary?.deletions,
|
||||
summary_files: input.summary?.files,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
return info
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const clearRevert = fn(SessionID.zod, async (sessionID) => {
|
||||
SyncEvent.run(Event.Updated, {
|
||||
sessionID,
|
||||
info: {
|
||||
time: { updated: Date.now() },
|
||||
revert: null,
|
||||
},
|
||||
return Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
revert: null,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
return info
|
||||
})
|
||||
})
|
||||
|
||||
@@ -449,12 +493,22 @@ export namespace Session {
|
||||
summary: Info.shape.summary,
|
||||
}),
|
||||
async (input) => {
|
||||
SyncEvent.run(Event.Updated, {
|
||||
sessionID: input.sessionID,
|
||||
info: {
|
||||
time: { updated: Date.now() },
|
||||
summary: input.summary,
|
||||
},
|
||||
return Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
summary_additions: input.summary?.additions,
|
||||
summary_deletions: input.summary?.deletions,
|
||||
summary_files: input.summary?.files,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
return info
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -608,28 +662,46 @@ export namespace Session {
|
||||
})
|
||||
|
||||
export const remove = fn(SessionID.zod, async (sessionID) => {
|
||||
const project = Instance.project
|
||||
try {
|
||||
const session = await get(sessionID)
|
||||
for (const child of await children(sessionID)) {
|
||||
await remove(child.id)
|
||||
}
|
||||
await unshare(sessionID).catch(() => {})
|
||||
|
||||
SyncEvent.run(Event.Deleted, { sessionID, info: session })
|
||||
|
||||
// Eagerly remove event sourcing data to free up space
|
||||
SyncEvent.remove(sessionID)
|
||||
// CASCADE delete handles messages and parts automatically
|
||||
Database.use((db) => {
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
|
||||
Database.effect(() =>
|
||||
Bus.publish(Event.Deleted, {
|
||||
info: session,
|
||||
}),
|
||||
)
|
||||
})
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
export const updateMessage = fn(MessageV2.Info, async (msg) => {
|
||||
SyncEvent.run(MessageV2.Event.Updated, {
|
||||
sessionID: msg.sessionID,
|
||||
info: msg,
|
||||
const time_created = msg.time.created
|
||||
const { id, sessionID, ...data } = msg
|
||||
Database.use((db) => {
|
||||
db.insert(MessageTable)
|
||||
.values({
|
||||
id,
|
||||
session_id: sessionID,
|
||||
time_created,
|
||||
data,
|
||||
})
|
||||
.onConflictDoUpdate({ target: MessageTable.id, set: { data } })
|
||||
.run()
|
||||
Database.effect(() =>
|
||||
Bus.publish(MessageV2.Event.Updated, {
|
||||
info: msg,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return msg
|
||||
})
|
||||
|
||||
@@ -639,9 +711,17 @@ export namespace Session {
|
||||
messageID: MessageID.zod,
|
||||
}),
|
||||
async (input) => {
|
||||
SyncEvent.run(MessageV2.Event.Removed, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
// CASCADE delete handles parts automatically
|
||||
Database.use((db) => {
|
||||
db.delete(MessageTable)
|
||||
.where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID)))
|
||||
.run()
|
||||
Database.effect(() =>
|
||||
Bus.publish(MessageV2.Event.Removed, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
}),
|
||||
)
|
||||
})
|
||||
return input.messageID
|
||||
},
|
||||
@@ -654,10 +734,17 @@ export namespace Session {
|
||||
partID: PartID.zod,
|
||||
}),
|
||||
async (input) => {
|
||||
SyncEvent.run(MessageV2.Event.PartRemoved, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
partID: input.partID,
|
||||
Database.use((db) => {
|
||||
db.delete(PartTable)
|
||||
.where(and(eq(PartTable.id, input.partID), eq(PartTable.session_id, input.sessionID)))
|
||||
.run()
|
||||
Database.effect(() =>
|
||||
Bus.publish(MessageV2.Event.PartRemoved, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
partID: input.partID,
|
||||
}),
|
||||
)
|
||||
})
|
||||
return input.partID
|
||||
},
|
||||
@@ -666,10 +753,24 @@ export namespace Session {
|
||||
const UpdatePartInput = MessageV2.Part
|
||||
|
||||
export const updatePart = fn(UpdatePartInput, async (part) => {
|
||||
SyncEvent.run(MessageV2.Event.PartUpdated, {
|
||||
sessionID: part.sessionID,
|
||||
part: structuredClone(part),
|
||||
time: Date.now(),
|
||||
const { id, messageID, sessionID, ...data } = part
|
||||
const time = Date.now()
|
||||
Database.use((db) => {
|
||||
db.insert(PartTable)
|
||||
.values({
|
||||
id,
|
||||
message_id: messageID,
|
||||
session_id: sessionID,
|
||||
time_created: time,
|
||||
data,
|
||||
})
|
||||
.onConflictDoUpdate({ target: PartTable.id, set: { data } })
|
||||
.run()
|
||||
Database.effect(() =>
|
||||
Bus.publish(MessageV2.Event.PartUpdated, {
|
||||
part: structuredClone(part),
|
||||
}),
|
||||
)
|
||||
})
|
||||
return part
|
||||
})
|
||||
|
||||
@@ -155,6 +155,7 @@ export namespace LLM {
|
||||
model: input.model,
|
||||
provider,
|
||||
message: input.user,
|
||||
small: input.small === true,
|
||||
},
|
||||
{
|
||||
headers: {},
|
||||
|
||||
@@ -6,22 +6,17 @@ import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessag
|
||||
import { LSP } from "../lsp"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { fn } from "@/util/fn"
|
||||
import { SyncEvent } from "../sync"
|
||||
import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db"
|
||||
import { MessageTable, PartTable, SessionTable } from "./session.sql"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { STATUS_CODES } from "http"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
import { iife } from "@/util/iife"
|
||||
import type { SystemError } from "bun"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
||||
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
|
||||
interface FetchDecompressionError extends Error {
|
||||
code: "ZlibError"
|
||||
errno: number
|
||||
path: string
|
||||
}
|
||||
|
||||
export namespace MessageV2 {
|
||||
export function isMedia(mime: string) {
|
||||
return mime.startsWith("image/") || mime === "application/pdf"
|
||||
@@ -454,34 +449,25 @@ export namespace MessageV2 {
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Event = {
|
||||
Updated: SyncEvent.define({
|
||||
type: "message.updated",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
Updated: BusEvent.define(
|
||||
"message.updated",
|
||||
z.object({
|
||||
info: Info,
|
||||
}),
|
||||
}),
|
||||
Removed: SyncEvent.define({
|
||||
type: "message.removed",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
),
|
||||
Removed: BusEvent.define(
|
||||
"message.removed",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod,
|
||||
}),
|
||||
}),
|
||||
PartUpdated: SyncEvent.define({
|
||||
type: "message.part.updated",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
),
|
||||
PartUpdated: BusEvent.define(
|
||||
"message.part.updated",
|
||||
z.object({
|
||||
part: Part,
|
||||
time: z.number(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
PartDelta: BusEvent.define(
|
||||
"message.part.delta",
|
||||
z.object({
|
||||
@@ -492,16 +478,14 @@ export namespace MessageV2 {
|
||||
delta: z.string(),
|
||||
}),
|
||||
),
|
||||
PartRemoved: SyncEvent.define({
|
||||
type: "message.part.removed",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
PartRemoved: BusEvent.define(
|
||||
"message.part.removed",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod,
|
||||
partID: PartID.zod,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export const WithParts = z.object({
|
||||
@@ -913,10 +897,7 @@ export namespace MessageV2 {
|
||||
return result
|
||||
}
|
||||
|
||||
export function fromError(
|
||||
e: unknown,
|
||||
ctx: { providerID: ProviderID; aborted?: boolean },
|
||||
): NonNullable<Assistant["error"]> {
|
||||
export function fromError(e: unknown, ctx: { providerID: ProviderID }): NonNullable<Assistant["error"]> {
|
||||
switch (true) {
|
||||
case e instanceof DOMException && e.name === "AbortError":
|
||||
return new MessageV2.AbortedError(
|
||||
@@ -948,21 +929,6 @@ export namespace MessageV2 {
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
case e instanceof Error && (e as FetchDecompressionError).code === "ZlibError":
|
||||
if (ctx.aborted) {
|
||||
return new MessageV2.AbortedError({ message: e.message }, { cause: e }).toObject()
|
||||
}
|
||||
return new MessageV2.APIError(
|
||||
{
|
||||
message: "Response decompression failed",
|
||||
isRetryable: true,
|
||||
metadata: {
|
||||
code: (e as FetchDecompressionError).code,
|
||||
message: e.message,
|
||||
},
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
case APICallError.isInstance(e):
|
||||
const parsed = ProviderError.parseAPICallError({
|
||||
providerID: ctx.providerID,
|
||||
|
||||
@@ -356,7 +356,7 @@ export namespace SessionProcessor {
|
||||
error: e,
|
||||
stack: JSON.stringify(e.stack),
|
||||
})
|
||||
const error = MessageV2.fromError(e, { providerID: input.model.providerID, aborted: input.abort.aborted })
|
||||
const error = MessageV2.fromError(e, { providerID: input.model.providerID })
|
||||
if (MessageV2.ContextOverflowError.isInstance(error)) {
|
||||
needsCompaction = true
|
||||
Bus.publish(Session.Event.Error, {
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { NotFoundError, eq, and } from "../storage/db"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Session } from "./index"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
const log = Log.create({ service: "session.projector" })
|
||||
|
||||
function foreign(err: unknown) {
|
||||
if (typeof err !== "object" || err === null) return false
|
||||
if ("code" in err && err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") return true
|
||||
return "message" in err && typeof err.message === "string" && err.message.includes("FOREIGN KEY constraint failed")
|
||||
}
|
||||
|
||||
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T
|
||||
|
||||
function grab<T extends object, K1 extends keyof T, X>(
|
||||
obj: T,
|
||||
field1: K1,
|
||||
cb?: (val: NonNullable<T[K1]>) => X,
|
||||
): X | undefined {
|
||||
if (obj == undefined || !(field1 in obj)) return undefined
|
||||
|
||||
const val = obj[field1]
|
||||
if (val && typeof val === "object" && cb) {
|
||||
return cb(val)
|
||||
}
|
||||
if (val === undefined) {
|
||||
throw new Error(
|
||||
"Session update failure: pass `null` to clear a field instead of `undefined`: " + JSON.stringify(obj),
|
||||
)
|
||||
}
|
||||
return val as X | undefined
|
||||
}
|
||||
|
||||
export function toPartialRow(info: DeepPartial<Session.Info>) {
|
||||
const obj = {
|
||||
id: grab(info, "id"),
|
||||
project_id: grab(info, "projectID"),
|
||||
workspace_id: grab(info, "workspaceID"),
|
||||
parent_id: grab(info, "parentID"),
|
||||
slug: grab(info, "slug"),
|
||||
directory: grab(info, "directory"),
|
||||
title: grab(info, "title"),
|
||||
version: grab(info, "version"),
|
||||
share_url: grab(info, "share", (v) => grab(v, "url")),
|
||||
summary_additions: grab(info, "summary", (v) => grab(v, "additions")),
|
||||
summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")),
|
||||
summary_files: grab(info, "summary", (v) => grab(v, "files")),
|
||||
summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")),
|
||||
revert: grab(info, "revert"),
|
||||
permission: grab(info, "permission"),
|
||||
time_created: grab(info, "time", (v) => grab(v, "created")),
|
||||
time_updated: grab(info, "time", (v) => grab(v, "updated")),
|
||||
time_compacting: grab(info, "time", (v) => grab(v, "compacting")),
|
||||
time_archived: grab(info, "time", (v) => grab(v, "archived")),
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(obj).filter(([_, val]) => val !== undefined))
|
||||
}
|
||||
|
||||
export default [
|
||||
SyncEvent.project(Session.Event.Created, (db, data) => {
|
||||
db.insert(SessionTable).values(Session.toRow(data.info)).run()
|
||||
}),
|
||||
|
||||
SyncEvent.project(Session.Event.Updated, (db, data) => {
|
||||
const info = data.info
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set(toPartialRow(info))
|
||||
.where(eq(SessionTable.id, data.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${data.sessionID}` })
|
||||
}),
|
||||
|
||||
SyncEvent.project(Session.Event.Deleted, (db, data) => {
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, data.sessionID)).run()
|
||||
}),
|
||||
|
||||
SyncEvent.project(MessageV2.Event.Updated, (db, data) => {
|
||||
const time_created = data.info.time.created
|
||||
const { id, sessionID, ...rest } = data.info
|
||||
|
||||
try {
|
||||
db.insert(MessageTable)
|
||||
.values({
|
||||
id,
|
||||
session_id: sessionID,
|
||||
time_created,
|
||||
data: rest,
|
||||
})
|
||||
.onConflictDoUpdate({ target: MessageTable.id, set: { data: rest } })
|
||||
.run()
|
||||
} catch (err) {
|
||||
if (!foreign(err)) throw err
|
||||
log.warn("ignored late message update", { messageID: id, sessionID })
|
||||
}
|
||||
}),
|
||||
|
||||
SyncEvent.project(MessageV2.Event.Removed, (db, data) => {
|
||||
db.delete(MessageTable)
|
||||
.where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID)))
|
||||
.run()
|
||||
}),
|
||||
|
||||
SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => {
|
||||
db.delete(PartTable)
|
||||
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
|
||||
.run()
|
||||
}),
|
||||
|
||||
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
|
||||
const { id, messageID, sessionID, ...rest } = data.part
|
||||
|
||||
try {
|
||||
db.insert(PartTable)
|
||||
.values({
|
||||
id,
|
||||
message_id: messageID,
|
||||
session_id: sessionID,
|
||||
time_created: data.time,
|
||||
data: rest,
|
||||
})
|
||||
.onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
|
||||
.run()
|
||||
} catch (err) {
|
||||
if (!foreign(err)) throw err
|
||||
log.warn("ignored late part update", { partID: id, messageID, sessionID })
|
||||
}
|
||||
}),
|
||||
]
|
||||
@@ -4,7 +4,8 @@ import { Snapshot } from "../snapshot"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Session } from "."
|
||||
import { Log } from "../util/log"
|
||||
import { SyncEvent } from "../sync"
|
||||
import { Database, eq } from "../storage/db"
|
||||
import { MessageTable, PartTable } from "./session.sql"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Bus } from "../bus"
|
||||
import { SessionPrompt } from "./prompt"
|
||||
@@ -112,10 +113,8 @@ export namespace SessionRevert {
|
||||
remove.push(msg)
|
||||
}
|
||||
for (const msg of remove) {
|
||||
SyncEvent.run(MessageV2.Event.Removed, {
|
||||
sessionID: sessionID,
|
||||
messageID: msg.info.id,
|
||||
})
|
||||
Database.use((db) => db.delete(MessageTable).where(eq(MessageTable.id, msg.info.id)).run())
|
||||
await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id })
|
||||
}
|
||||
if (session.revert.partID && target) {
|
||||
const partID = session.revert.partID
|
||||
@@ -125,7 +124,8 @@ export namespace SessionRevert {
|
||||
const removeParts = target.parts.slice(removeStart)
|
||||
target.parts = preserveParts
|
||||
for (const part of removeParts) {
|
||||
SyncEvent.run(MessageV2.Event.PartRemoved, {
|
||||
Database.use((db) => db.delete(PartTable).where(eq(PartTable.id, part.id)).run())
|
||||
await Bus.publish(MessageV2.Event.PartRemoved, {
|
||||
sessionID: sessionID,
|
||||
messageID: target.info.id,
|
||||
partID: part.id,
|
||||
|
||||
@@ -66,28 +66,29 @@ export namespace ShareNext {
|
||||
export async function init() {
|
||||
if (disabled) return
|
||||
Bus.subscribe(Session.Event.Updated, async (evt) => {
|
||||
const session = await Session.get(evt.properties.sessionID)
|
||||
|
||||
await sync(session.id, [
|
||||
await sync(evt.properties.info.id, [
|
||||
{
|
||||
type: "session",
|
||||
data: session,
|
||||
data: evt.properties.info,
|
||||
},
|
||||
])
|
||||
})
|
||||
Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
|
||||
const info = evt.properties.info
|
||||
await sync(info.sessionID, [
|
||||
await sync(evt.properties.info.sessionID, [
|
||||
{
|
||||
type: "message",
|
||||
data: evt.properties.info,
|
||||
},
|
||||
])
|
||||
if (info.role === "user") {
|
||||
await sync(info.sessionID, [
|
||||
if (evt.properties.info.role === "user") {
|
||||
await sync(evt.properties.info.sessionID, [
|
||||
{
|
||||
type: "model",
|
||||
data: [await Provider.getModel(info.model.providerID, info.model.modelID).then((m) => m)],
|
||||
data: [
|
||||
await Provider.getModel(evt.properties.info.model.providerID, evt.properties.info.model.modelID).then(
|
||||
(m) => m,
|
||||
),
|
||||
],
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
@@ -7,7 +7,6 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Hash } from "@/util/hash"
|
||||
import { Config } from "../config/config"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
@@ -35,10 +34,10 @@ export namespace Snapshot {
|
||||
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const prune = "7.days"
|
||||
const limit = 2 * 1024 * 1024
|
||||
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
||||
const cfg = ["-c", "core.autocrlf=false", ...core]
|
||||
const quote = [...cfg, "-c", "core.quotepath=false"]
|
||||
|
||||
interface GitResult {
|
||||
readonly code: ChildProcessSpawner.ExitCode
|
||||
readonly text: string
|
||||
@@ -66,23 +65,12 @@ export namespace Snapshot {
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const locks = new Map<string, Semaphore.Semaphore>()
|
||||
|
||||
const lock = (key: string) => {
|
||||
const hit = locks.get(key)
|
||||
if (hit) return hit
|
||||
|
||||
const next = Semaphore.makeUnsafe(1)
|
||||
locks.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Snapshot.state")(function* (ctx) {
|
||||
const state = {
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
|
||||
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id),
|
||||
vcs: ctx.project.vcs,
|
||||
}
|
||||
|
||||
@@ -119,7 +107,6 @@ export namespace Snapshot {
|
||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||
const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
|
||||
|
||||
const enabled = Effect.fnUntraced(function* () {
|
||||
if (state.vcs !== "git") return false
|
||||
@@ -136,277 +123,192 @@ export namespace Snapshot {
|
||||
return file
|
||||
})
|
||||
|
||||
const sync = Effect.fnUntraced(function* (list: string[] = []) {
|
||||
const sync = Effect.fnUntraced(function* () {
|
||||
const file = yield* excludes()
|
||||
const target = path.join(state.gitdir, "info", "exclude")
|
||||
const text = [
|
||||
file ? (yield* read(file)).trimEnd() : "",
|
||||
...list.map((item) => `/${item.replaceAll("\\", "/")}`),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
|
||||
if (!file) {
|
||||
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
|
||||
return
|
||||
}
|
||||
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const add = Effect.fnUntraced(function* () {
|
||||
yield* sync()
|
||||
const [diff, other] = yield* Effect.all(
|
||||
[
|
||||
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
}),
|
||||
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
}),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
if (diff.code !== 0 || other.code !== 0) {
|
||||
log.warn("failed to list snapshot files", {
|
||||
diffCode: diff.code,
|
||||
diffStderr: diff.stderr,
|
||||
otherCode: other.code,
|
||||
otherStderr: other.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const tracked = diff.text.split("\0").filter(Boolean)
|
||||
const all = Array.from(new Set([...tracked, ...other.text.split("\0").filter(Boolean)]))
|
||||
if (!all.length) return
|
||||
|
||||
const large = (yield* Effect.all(
|
||||
all.map((item) =>
|
||||
fs
|
||||
.stat(path.join(state.directory, item))
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
.pipe(
|
||||
Effect.map((stat) => {
|
||||
if (!stat || stat.type !== "File") return
|
||||
const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
|
||||
return size > limit ? item : undefined
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ concurrency: 8 },
|
||||
)).filter((item): item is string => Boolean(item))
|
||||
yield* sync(large)
|
||||
const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to add snapshot files", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
}
|
||||
yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory })
|
||||
})
|
||||
|
||||
const cleanup = Effect.fnUntraced(function* () {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
if (!(yield* exists(state.gitdir))) return
|
||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
}),
|
||||
)
|
||||
if (!(yield* enabled())) return
|
||||
if (!(yield* exists(state.gitdir))) return
|
||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* () {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
const existed = yield* exists(state.gitdir)
|
||||
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
||||
})
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add()
|
||||
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
||||
return hash
|
||||
}),
|
||||
)
|
||||
if (!(yield* enabled())) return
|
||||
const existed = yield* exists(state.gitdir)
|
||||
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
||||
})
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add()
|
||||
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
||||
return hash
|
||||
})
|
||||
|
||||
const patch = Effect.fnUntraced(function* (hash: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
}),
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
})
|
||||
|
||||
const restore = Effect.fnUntraced(function* (snapshot: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
}),
|
||||
)
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: state.worktree })
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
})
|
||||
|
||||
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
const rel = path.relative(state.worktree, file)
|
||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (tree.code === 0 && tree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
yield* remove(file)
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
const rel = path.relative(state.worktree, file)
|
||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (tree.code === 0 && tree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
yield* remove(file)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const diff = Effect.fnUntraced(function* (hash: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.worktree,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return result.text.trim()
|
||||
}),
|
||||
)
|
||||
yield* add()
|
||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return result.text.trim()
|
||||
})
|
||||
|
||||
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
const result: Snapshot.FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
const result: Snapshot.FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = yield* git(
|
||||
[
|
||||
...quote,
|
||||
...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
|
||||
],
|
||||
{ cwd: state.directory },
|
||||
)
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [adds, dels, file] = line.split("\t")
|
||||
if (!file) continue
|
||||
const binary = adds === "-" && dels === "-"
|
||||
const [before, after] = binary
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const additions = binary ? 0 : parseInt(adds)
|
||||
const deletions = binary ? 0 : parseInt(dels)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}),
|
||||
const statuses = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||
{ cwd: state.directory },
|
||||
)
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [adds, dels, file] = line.split("\t")
|
||||
if (!file) continue
|
||||
const binary = adds === "-" && dels === "-"
|
||||
const [before, after] = binary
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const additions = binary ? 0 : parseInt(adds)
|
||||
const deletions = binary ? 0 : parseInt(dels)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
|
||||
@@ -27,20 +27,16 @@ export const NotFoundError = NamedError.create(
|
||||
const log = Log.create({ service: "db" })
|
||||
|
||||
export namespace Database {
|
||||
export function getChannelPath() {
|
||||
export const Path = iife(() => {
|
||||
if (Flag.OPENCODE_DB) {
|
||||
if (path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
|
||||
return path.join(Global.Path.data, Flag.OPENCODE_DB)
|
||||
}
|
||||
const channel = Installation.CHANNEL
|
||||
if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
|
||||
return path.join(Global.Path.data, "opencode.db")
|
||||
const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
return path.join(Global.Path.data, `opencode-${safe}.db`)
|
||||
}
|
||||
|
||||
export const Path = iife(() => {
|
||||
if (Flag.OPENCODE_DB) {
|
||||
if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
|
||||
return path.join(Global.Path.data, Flag.OPENCODE_DB)
|
||||
}
|
||||
return getChannelPath()
|
||||
})
|
||||
|
||||
export type Transaction = SQLiteTransaction<"sync", void>
|
||||
@@ -149,27 +145,17 @@ export namespace Database {
|
||||
}
|
||||
}
|
||||
|
||||
type NotPromise<T> = T extends Promise<any> ? never : T
|
||||
|
||||
export function transaction<T>(
|
||||
callback: (tx: TxOrDb) => NotPromise<T>,
|
||||
options?: {
|
||||
behavior?: "deferred" | "immediate" | "exclusive"
|
||||
},
|
||||
): NotPromise<T> {
|
||||
export function transaction<T>(callback: (tx: TxOrDb) => T): T {
|
||||
try {
|
||||
return callback(ctx.use().tx)
|
||||
} catch (err) {
|
||||
if (err instanceof Context.NotFound) {
|
||||
const effects: (() => void | Promise<void>)[] = []
|
||||
const result = Client().transaction(
|
||||
(tx: TxOrDb) => {
|
||||
return ctx.provide({ tx, effects }, () => callback(tx))
|
||||
},
|
||||
{ behavior: options?.behavior },
|
||||
)
|
||||
const result = (Client().transaction as any)((tx: TxOrDb) => {
|
||||
return ctx.provide({ tx, effects }, () => callback(tx))
|
||||
})
|
||||
for (const effect of effects) effect()
|
||||
return result as NotPromise<T>
|
||||
return result
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { lazy } from "../util/lazy"
|
||||
import { Lock } from "../util/lock"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod"
|
||||
import { Git } from "@/git"
|
||||
import { Glob } from "../util/glob"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
export namespace Storage {
|
||||
const log = Log.create({ service: "storage" })
|
||||
@@ -49,7 +49,7 @@ export namespace Storage {
|
||||
}
|
||||
if (!worktree) continue
|
||||
if (!(await Filesystem.isDir(worktree))) continue
|
||||
const result = await Git.run(["rev-list", "--max-parents=0", "--all"], {
|
||||
const result = await git(["rev-list", "--max-parents=0", "--all"], {
|
||||
cwd: worktree,
|
||||
})
|
||||
const [id] = result
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
tl;dr All of these APIs work, are properly type-checked, and are sync events are backwards compatible with `Bus`:
|
||||
|
||||
```ts
|
||||
// The schema from `Updated` typechecks the object correctly
|
||||
SyncEvent.run(Updated, { sessionID: id, info: { title: "foo" } })
|
||||
|
||||
// `subscribeAll` passes a generic sync event
|
||||
SyncEvent.subscribeAll((event) => {
|
||||
// These will be type-checked correctly
|
||||
event.id
|
||||
event.seq
|
||||
// This will be unknown because we are listening for all events,
|
||||
// and this API is only used to record them
|
||||
event.data
|
||||
})
|
||||
|
||||
// This works, but you shouldn't publish sync event like this (should fail in the future)
|
||||
Bus.publish(Updated, { sessionID: id, info: { title: "foo" } })
|
||||
|
||||
// Update event is fully type-checked
|
||||
Bus.subscribe(Updated, (event) => event.properties.info.title)
|
||||
|
||||
// Update event is fully type-checked
|
||||
client.subscribe("session.updated", (evt) => evt.properties.info.title)
|
||||
```
|
||||
|
||||
# Goal
|
||||
|
||||
## Syncing with only one writer
|
||||
|
||||
This system defines a basic event sourcing system for session replayability. The goal is to allow for one device to control and modify the session, and allow multiple other devices to "sync" session data. The sync works by getting a log of events to replay and replaying them locally.
|
||||
|
||||
Because only one device is allowed to write, we don't need any kind of sophisticated distributed system clocks or causal ordering. We implement total ordering with a simple sequence id (a number) and increment it by one every time we generate an event.
|
||||
|
||||
## Bus event integration and backwards compatibility
|
||||
|
||||
This initial implementation aims to be fully backwards compatible. We should be able to land this without any visible changes to the user.
|
||||
|
||||
An existing `Bus` abstraction to send events already exists. We already send events like `session.created` through the system. We should not duplicate this.
|
||||
|
||||
The difference in event sourcing is events are sent _before_ the mutation happens, and "projectors" handle the effects and perform the mutations. This difference is subtle, and a necessary change for syncing to work.
|
||||
|
||||
So the goal is:
|
||||
|
||||
- Introduce a new syncing abstraction to handle event sourcing and projectors
|
||||
- Seamlessly integrate these new events into the same existing `Bus` abstraction
|
||||
- Maintain full backwards compatibility to reduce risk
|
||||
|
||||
## My approach
|
||||
|
||||
This directory introduces a new abstraction: `SyncEvent`. This handles all of the event sourcing.
|
||||
|
||||
There are now "sync events" which are different than "bus events". Bus events are defined like this:
|
||||
|
||||
```ts
|
||||
const Diff = BusEvent.define(
|
||||
"session.diff",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
diff: Snapshot.FileDiff.array(),
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
You can do `Bus.publish(Diff, { ... })` to push these events, and `Bus.subscribe(Diff, handler)` to listen to them.
|
||||
|
||||
Sync events are a lower-level abstraction which are similar, but also handle the requirements for recording and replaying. Defining them looks like this:
|
||||
|
||||
```ts
|
||||
const Created = SyncEvent.define({
|
||||
type: "session.created",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: Info,
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Not too different, except they track a version and an "aggregate" field (will explain that later).
|
||||
|
||||
You do this to run an event, which is kind of like `Bus.publish` except that it runs through the event sourcing system:
|
||||
|
||||
```
|
||||
SyncEvent.run(Created, { ... })
|
||||
```
|
||||
|
||||
The data passed as the second argument is properly type-checked based on the schema defined in `Created`.
|
||||
|
||||
Importantly, **sync events automatically re-publish as bus events**. This makes them backwards compatible, and allows the `Bus` to still be the single abstraction that the system uses to listen for individual events.
|
||||
|
||||
**We have upgraded many of the session events to be sync events** (all of the ones that mutate the db). Sync and bus events are largely compatible. Here are the differences:
|
||||
|
||||
### Event shape
|
||||
|
||||
- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event throught the bus.
|
||||
|
||||
The reason for this is because sync events need to track more information. I chose not to copy the `properties` naming to more clearly disambiguate the event types.
|
||||
|
||||
### Event flow
|
||||
|
||||
There is no way to subscribe to individual sync events in `SyncEvent`. You can use `subscribeAll` to receive _all_ of the events, which is needed for clients that want to record them.
|
||||
|
||||
To listen for individual events, use `Bus.subscribe`. You can pass in a sync event definition to it: `Bus.subscribe(Created, handler)`. This is fully supported.
|
||||
|
||||
You should never "publish" a sync event however: `Bus.publish(Created, ...)`. I would like to force this to be a type error in the future. You should never be touching the db directly, and should not be manually handling these events.
|
||||
|
||||
### Backwards compatibility
|
||||
|
||||
The system install projectors in `server/projectors.js`. It calls `SyncEvent.init` to do this. It also installs a hook for dynamically converting an event at runtime (`convertEvent`).
|
||||
|
||||
This allows you to "reshape" an event from the sync system before it's published to the bus. This should be avoided, but might be necessary for temporary backwards compat.
|
||||
|
||||
The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync even only contains the fields updated. We convert the event to contain to full object for backwards compatibility (but ideally we'd remove this).
|
||||
|
||||
It's very important that types are correct when working with events. Event definitions have a `schema` which carries the defintiion of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples:
|
||||
|
||||
```ts
|
||||
// The schema from `Updated` typechecks the object correctly
|
||||
SyncEvent.run(Updated, { sessionID: id, info: { title: "foo" } })
|
||||
|
||||
// `subscribeAll` passes a generic sync event
|
||||
SyncEvent.subscribeAll((event) => {
|
||||
// These will be type-checked correctly
|
||||
event.id
|
||||
event.seq
|
||||
// This will be unknown because we are listening for all events,
|
||||
// and this API is only used to record them
|
||||
event.data
|
||||
})
|
||||
|
||||
// This works, but you shouldn't publish sync event like this (should fail in the future)
|
||||
Bus.publish(Updated, { sessionID: id, info: { title: "foo" } })
|
||||
|
||||
// Update event is fully type-checked
|
||||
Bus.subscribe(Updated, (event) => event.properties.info.title)
|
||||
|
||||
// Update event is fully type-checked
|
||||
client.subscribe("session.updated", (evt) => evt.properties.info.title)
|
||||
```
|
||||
|
||||
The last two examples look similar to `SyncEvent.run`, but they were the cause of a lot of grief. Those are existing APIs that we can't break, but we are passing in the new sync event definitions to these APIs, which sometimes have a different event shape.
|
||||
|
||||
I previously mentioned the runtime conversion of events, but we still need to the types to work! To do that, the `define` API supports an optional `busSchema` prop to give it the schema for backwards compatibility. For example this is the full definition of `Session.Update`:
|
||||
|
||||
```ts
|
||||
const Update = SyncEvent.define({
|
||||
type: "session.updated",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: partialSchema(Info),
|
||||
}),
|
||||
busSchema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: Info,
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
_Important_: the conversion done in `convertEvent` is not automatically type-checked with `busSchema`. It's very important they match, but because we need this at type-checking time this needs to live here.
|
||||
|
||||
Internally, the way this works is `busSchema` is stored on a `properties` field which is what the bus system expects. Doing this made everything with `Bus` "just work". This is why you can pass a sync event to the bus APIs.
|
||||
|
||||
_Alternatives_
|
||||
|
||||
These are some other paths I explored:
|
||||
|
||||
- Providing a way to subscribe to individual sync events, and change all the instances of `Bus.subscribe` in our code to it. Then you are directly only working with sync events always.
|
||||
- Two big problems. First, `Bus` is instance-scoped, and we'd need to make the sync event system instance-scoped too for backwards compat. If we didn't, those listeners would get calls for events they weren't expecting.
|
||||
- Second, we can't change consumers of our SDK. So they still have to use the old events, and we might as well stick with them for consistency
|
||||
- Directly add sync event support to bus system
|
||||
- I explored adding sync events to the bus, but due to backwards compat, it only made it more complicated (still need to support both shapes)
|
||||
- I explored a `convertSchema` function to convert the event schema at runtime so we didn't need `busSchema`
|
||||
- Fatal flaw: we need type-checking done earlier. We can't do this at run-time. This worked for consumers of our SDK (because it gets generated TS types from the converted schema) but breaks for our internal usage of `Bus.subscribe` calls
|
||||
|
||||
I explored many other permutations of the above solutions. What we have today I think is the best balance of backwards compatibility while opening a path forward for the new events.
|
||||
@@ -1,16 +0,0 @@
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
|
||||
|
||||
export const EventSequenceTable = sqliteTable("event_sequence", {
|
||||
aggregate_id: text().notNull().primaryKey(),
|
||||
seq: integer().notNull(),
|
||||
})
|
||||
|
||||
export const EventTable = sqliteTable("event", {
|
||||
id: text().primaryKey(),
|
||||
aggregate_id: text()
|
||||
.notNull()
|
||||
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
|
||||
seq: integer().notNull(),
|
||||
type: text().notNull(),
|
||||
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
|
||||
})
|
||||
@@ -1,263 +0,0 @@
|
||||
import z from "zod"
|
||||
import type { ZodObject } from "zod"
|
||||
import { EventEmitter } from "events"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { Bus as ProjectBus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { EventSequenceTable, EventTable } from "./event.sql"
|
||||
import { EventID } from "./schema"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
export namespace SyncEvent {
|
||||
export type Definition = {
|
||||
type: string
|
||||
version: number
|
||||
aggregate: string
|
||||
schema: z.ZodObject
|
||||
|
||||
// This is temporary and only exists for compatibility with bus
|
||||
// event definitions
|
||||
properties: z.ZodObject
|
||||
}
|
||||
|
||||
export type Event<Def extends Definition = Definition> = {
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: z.infer<Def["schema"]>
|
||||
}
|
||||
|
||||
export type SerializedEvent<Def extends Definition = Definition> = Event<Def> & { type: string }
|
||||
|
||||
type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
|
||||
|
||||
export const registry = new Map<string, Definition>()
|
||||
let projectors: Map<Definition, ProjectorFunc> | undefined
|
||||
const versions = new Map<string, number>()
|
||||
let frozen = false
|
||||
let convertEvent: (type: string, event: Event["data"]) => Promise<Record<string, unknown>> | Record<string, unknown>
|
||||
|
||||
const Bus = new EventEmitter<{ event: [{ def: Definition; event: Event }] }>()
|
||||
|
||||
export function reset() {
|
||||
frozen = false
|
||||
projectors = undefined
|
||||
convertEvent = (_, data) => data
|
||||
}
|
||||
|
||||
export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: typeof convertEvent }) {
|
||||
projectors = new Map(input.projectors)
|
||||
|
||||
// Install all the latest event defs to the bus. We only ever emit
|
||||
// latest versions from code, and keep around old versions for
|
||||
// replaying. Replaying does not go through the bus, and it
|
||||
// simplifies the bus to only use unversioned latest events
|
||||
for (let [type, version] of versions.entries()) {
|
||||
let def = registry.get(versionedType(type, version))!
|
||||
|
||||
BusEvent.define(def.type, def.properties || def.schema)
|
||||
}
|
||||
|
||||
// Freeze the system so it clearly errors if events are defined
|
||||
// after `init` which would cause bugs
|
||||
frozen = true
|
||||
convertEvent = input.convertEvent || ((_, data) => data)
|
||||
}
|
||||
|
||||
export function versionedType<A extends string>(type: A): A
|
||||
export function versionedType<A extends string, B extends number>(type: A, version: B): `${A}/${B}`
|
||||
export function versionedType(type: string, version?: number) {
|
||||
return version ? `${type}.${version}` : type
|
||||
}
|
||||
|
||||
export function define<
|
||||
Type extends string,
|
||||
Agg extends string,
|
||||
Schema extends ZodObject<Record<Agg, z.ZodType<string>>>,
|
||||
BusSchema extends ZodObject = Schema,
|
||||
>(input: { type: Type; version: number; aggregate: Agg; schema: Schema; busSchema?: BusSchema }) {
|
||||
if (frozen) {
|
||||
throw new Error("Error defining sync event: sync system has been frozen")
|
||||
}
|
||||
|
||||
const def = {
|
||||
type: input.type,
|
||||
version: input.version,
|
||||
aggregate: input.aggregate,
|
||||
schema: input.schema,
|
||||
properties: input.busSchema ? input.busSchema : input.schema,
|
||||
}
|
||||
|
||||
versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0))
|
||||
|
||||
registry.set(versionedType(def.type, def.version), def)
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
export function project<Def extends Definition>(
|
||||
def: Def,
|
||||
func: (db: Database.TxOrDb, data: Event<Def>["data"]) => void,
|
||||
): [Definition, ProjectorFunc] {
|
||||
return [def, func as ProjectorFunc]
|
||||
}
|
||||
|
||||
function process<Def extends Definition>(def: Def, event: Event<Def>, options: { publish: boolean }) {
|
||||
if (projectors == null) {
|
||||
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
|
||||
}
|
||||
|
||||
const projector = projectors.get(def)
|
||||
if (!projector) {
|
||||
throw new Error(`Projector not found for event: ${def.type}`)
|
||||
}
|
||||
|
||||
// idempotent: need to ignore any events already logged
|
||||
|
||||
Database.transaction((tx) => {
|
||||
projector(tx, event.data)
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
tx.insert(EventSequenceTable)
|
||||
.values({
|
||||
aggregate_id: event.aggregateID,
|
||||
seq: event.seq,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: event.seq },
|
||||
})
|
||||
.run()
|
||||
tx.insert(EventTable)
|
||||
.values({
|
||||
id: event.id,
|
||||
seq: event.seq,
|
||||
aggregate_id: event.aggregateID,
|
||||
type: versionedType(def.type, def.version),
|
||||
data: event.data as Record<string, unknown>,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
Database.effect(() => {
|
||||
Bus.emit("event", {
|
||||
def,
|
||||
event,
|
||||
})
|
||||
|
||||
if (options?.publish) {
|
||||
const result = convertEvent(def.type, event.data)
|
||||
if (result instanceof Promise) {
|
||||
result.then((data) => {
|
||||
ProjectBus.publish({ type: def.type, properties: def.schema }, data)
|
||||
})
|
||||
} else {
|
||||
ProjectBus.publish({ type: def.type, properties: def.schema }, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TODO:
|
||||
//
|
||||
// * Support applying multiple events at one time. One transaction,
|
||||
// and it validets all the sequence ids
|
||||
// * when loading events from db, apply zod validation to ensure shape
|
||||
|
||||
export function replay(event: SerializedEvent, options?: { republish: boolean }) {
|
||||
const def = registry.get(event.type)
|
||||
if (!def) {
|
||||
throw new Error(`Unknown event type: ${event.type}`)
|
||||
}
|
||||
|
||||
const row = Database.use((db) =>
|
||||
db
|
||||
.select({ seq: EventSequenceTable.seq })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
|
||||
.get(),
|
||||
)
|
||||
|
||||
const latest = row?.seq ?? -1
|
||||
if (event.seq <= latest) {
|
||||
return
|
||||
}
|
||||
|
||||
const expected = latest + 1
|
||||
if (event.seq !== expected) {
|
||||
throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
|
||||
}
|
||||
|
||||
process(def, event, { publish: !!options?.republish })
|
||||
}
|
||||
|
||||
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"]) {
|
||||
const agg = (data as Record<string, string>)[def.aggregate]
|
||||
// This should never happen: we've enforced it via typescript in
|
||||
// the definition
|
||||
if (agg == null) {
|
||||
throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`)
|
||||
}
|
||||
|
||||
if (def.version !== versions.get(def.type)) {
|
||||
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
|
||||
}
|
||||
|
||||
// Note that this is an "immediate" transaction which is critical.
|
||||
// We need to make sure we can safely read and write with nothing
|
||||
// else changing the data from under us
|
||||
Database.transaction(
|
||||
(tx) => {
|
||||
const id = EventID.ascending()
|
||||
const row = tx
|
||||
.select({ seq: EventSequenceTable.seq })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, agg))
|
||||
.get()
|
||||
const seq = row?.seq != null ? row.seq + 1 : 0
|
||||
|
||||
const event = { id, seq, aggregateID: agg, data }
|
||||
process(def, event, { publish: true })
|
||||
},
|
||||
{
|
||||
behavior: "immediate",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function remove(aggregateID: string) {
|
||||
Database.transaction((tx) => {
|
||||
tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
|
||||
tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
|
||||
})
|
||||
}
|
||||
|
||||
export function subscribeAll(handler: (event: { def: Definition; event: Event }) => void) {
|
||||
Bus.on("event", handler)
|
||||
return () => Bus.off("event", handler)
|
||||
}
|
||||
|
||||
export function payloads() {
|
||||
return z
|
||||
.union(
|
||||
registry
|
||||
.entries()
|
||||
.map(([type, def]) => {
|
||||
return z
|
||||
.object({
|
||||
type: z.literal(type),
|
||||
aggregate: z.literal(def.aggregate),
|
||||
data: def.schema,
|
||||
})
|
||||
.meta({
|
||||
ref: "SyncEvent" + "." + def.type,
|
||||
})
|
||||
})
|
||||
.toArray() as any,
|
||||
)
|
||||
.meta({
|
||||
ref: "SyncEvent",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
export const EventID = Schema.String.pipe(
|
||||
Schema.brand("EventID"),
|
||||
withStatics((s) => ({
|
||||
make: (id: string) => s.makeUnsafe(id),
|
||||
ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("event", id)),
|
||||
zod: Identifier.schema("event").pipe(z.custom<Schema.Schema.Type<typeof s>>()),
|
||||
})),
|
||||
)
|
||||
@@ -64,7 +64,6 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
|
||||
|
||||
const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
|
||||
const hasTodoWritePermission = agent.permission.some((rule) => rule.permission === "todowrite")
|
||||
|
||||
const session = await iife(async () => {
|
||||
if (params.task_id) {
|
||||
@@ -76,15 +75,16 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
parentID: ctx.sessionID,
|
||||
title: params.description + ` (@${agent.name} subagent)`,
|
||||
permission: [
|
||||
...(hasTodoWritePermission
|
||||
? []
|
||||
: [
|
||||
{
|
||||
permission: "todowrite" as const,
|
||||
pattern: "*" as const,
|
||||
action: "deny" as const,
|
||||
},
|
||||
]),
|
||||
{
|
||||
permission: "todowrite",
|
||||
pattern: "*",
|
||||
action: "deny",
|
||||
},
|
||||
{
|
||||
permission: "todoread",
|
||||
pattern: "*",
|
||||
action: "deny",
|
||||
},
|
||||
...(hasTaskPermission
|
||||
? []
|
||||
: [
|
||||
@@ -136,7 +136,8 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
},
|
||||
agent: agent.name,
|
||||
tools: {
|
||||
...(hasTodoWritePermission ? {} : { todowrite: false }),
|
||||
todowrite: false,
|
||||
todoread: false,
|
||||
...(hasTaskPermission ? {} : { task: false }),
|
||||
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ export const TodoWriteTool = Tool.define("todowrite", {
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
Todo.update({
|
||||
await Todo.update({
|
||||
sessionID: ctx.sessionID,
|
||||
todos: params.todos,
|
||||
})
|
||||
@@ -29,3 +29,25 @@ export const TodoWriteTool = Tool.define("todowrite", {
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const TodoReadTool = Tool.define("todoread", {
|
||||
description: "Use this tool to read your todo list",
|
||||
parameters: z.object({}),
|
||||
async execute(_params, ctx) {
|
||||
await ctx.ask({
|
||||
permission: "todoread",
|
||||
patterns: ["*"],
|
||||
always: ["*"],
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
const todos = await Todo.get(ctx.sessionID)
|
||||
return {
|
||||
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
|
||||
metadata: {
|
||||
todos,
|
||||
},
|
||||
output: JSON.stringify(todos, null, 2),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
Use this tool to read the current to-do list for the session. This tool should be used proactively and frequently to ensure that you are aware of
|
||||
the status of the current task list. You should make use of this tool as often as possible, especially in the following situations:
|
||||
- At the beginning of conversations to see what's pending
|
||||
- Before starting new tasks to prioritize work
|
||||
- When the user asks about previous tasks or plans
|
||||
- Whenever you're uncertain about what to do next
|
||||
- After completing tasks to update your understanding of remaining work
|
||||
- After every few messages to ensure you're on track
|
||||
|
||||
Usage:
|
||||
- This tool takes in no parameters. So leave the input blank or empty. DO NOT include a dummy object, placeholder string or a key like "input" or "empty". LEAVE IT BLANK.
|
||||
- Returns a list of todo items with their status, priority, and content
|
||||
- Use this information to track progress and plan next steps
|
||||
- If no todos exist yet, an empty list will be returned
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Process } from "./process"
|
||||
|
||||
export interface GitResult {
|
||||
exitCode: number
|
||||
text(): string
|
||||
stdout: Buffer
|
||||
stderr: Buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a git command.
|
||||
*
|
||||
* Uses Process helpers with stdin ignored to avoid protocol pipe inheritance
|
||||
* issues in embedded/client environments.
|
||||
*/
|
||||
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
|
||||
return Process.run(["git", ...args], {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
stdin: "ignore",
|
||||
nothrow: true,
|
||||
})
|
||||
.then((result) => ({
|
||||
exitCode: result.code,
|
||||
text: () => result.stdout.toString(),
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
}))
|
||||
.catch((error) => ({
|
||||
exitCode: 1,
|
||||
text: () => "",
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.from(error instanceof Error ? error.message : String(error)),
|
||||
}))
|
||||
}
|
||||
@@ -22,6 +22,10 @@ export namespace Log {
|
||||
return levelPriority[input] >= levelPriority[level]
|
||||
}
|
||||
|
||||
function shouldServiceLog(service: unknown) {
|
||||
return service === "plugin.openai.websocket"
|
||||
}
|
||||
|
||||
export type Logger = {
|
||||
debug(message?: any, extra?: Record<string, any>): void
|
||||
info(message?: any, extra?: Record<string, any>): void
|
||||
@@ -62,8 +66,10 @@ export namespace Log {
|
||||
cleanup(Global.Path.log)
|
||||
if (options.print) return
|
||||
logpath = path.join(
|
||||
Global.Path.log,
|
||||
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
|
||||
// TODO: STOP DOING THIS!!!!!
|
||||
"dev.log",
|
||||
// Global.Path.log,
|
||||
// options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
|
||||
)
|
||||
await fs.truncate(logpath).catch(() => {})
|
||||
const stream = createWriteStream(logpath, { flags: "a" })
|
||||
@@ -109,6 +115,7 @@ export namespace Log {
|
||||
}
|
||||
|
||||
function build(message: any, extra?: Record<string, any>) {
|
||||
if (!shouldServiceLog(tags?.["service"])) return
|
||||
const prefix = Object.entries({
|
||||
...tags,
|
||||
...extra,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import z from "zod"
|
||||
|
||||
export function updateSchema<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
|
||||
const next = {} as {
|
||||
[K in keyof T]: z.ZodOptional<z.ZodNullable<T[K]>>
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(schema.required().shape) as [keyof T & string, z.ZodTypeAny][]) {
|
||||
next[k] = v.nullable() as unknown as (typeof next)[typeof k]
|
||||
}
|
||||
|
||||
return z.object(next)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Global } from "../global"
|
||||
@@ -7,16 +9,12 @@ import { Project } from "../project/project"
|
||||
import { Database, eq } from "../storage/db"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import type { ProjectID } from "../project/schema"
|
||||
import { fn } from "../util/fn"
|
||||
import { Log } from "../util/log"
|
||||
import { Slug } from "@opencode-ai/util/slug"
|
||||
import { Process } from "../util/process"
|
||||
import { git } from "../util/git"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Git } from "@/git"
|
||||
import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
||||
export namespace Worktree {
|
||||
const log = Log.create({ service: "worktree" })
|
||||
@@ -125,7 +123,77 @@ export namespace Worktree {
|
||||
}),
|
||||
)
|
||||
|
||||
function slugify(input: string) {
|
||||
const ADJECTIVES = [
|
||||
"brave",
|
||||
"calm",
|
||||
"clever",
|
||||
"cosmic",
|
||||
"crisp",
|
||||
"curious",
|
||||
"eager",
|
||||
"gentle",
|
||||
"glowing",
|
||||
"happy",
|
||||
"hidden",
|
||||
"jolly",
|
||||
"kind",
|
||||
"lucky",
|
||||
"mighty",
|
||||
"misty",
|
||||
"neon",
|
||||
"nimble",
|
||||
"playful",
|
||||
"proud",
|
||||
"quick",
|
||||
"quiet",
|
||||
"shiny",
|
||||
"silent",
|
||||
"stellar",
|
||||
"sunny",
|
||||
"swift",
|
||||
"tidy",
|
||||
"witty",
|
||||
] as const
|
||||
|
||||
const NOUNS = [
|
||||
"cabin",
|
||||
"cactus",
|
||||
"canyon",
|
||||
"circuit",
|
||||
"comet",
|
||||
"eagle",
|
||||
"engine",
|
||||
"falcon",
|
||||
"forest",
|
||||
"garden",
|
||||
"harbor",
|
||||
"island",
|
||||
"knight",
|
||||
"lagoon",
|
||||
"meadow",
|
||||
"moon",
|
||||
"mountain",
|
||||
"nebula",
|
||||
"orchid",
|
||||
"otter",
|
||||
"panda",
|
||||
"pixel",
|
||||
"planet",
|
||||
"river",
|
||||
"rocket",
|
||||
"sailor",
|
||||
"squid",
|
||||
"star",
|
||||
"tiger",
|
||||
"wizard",
|
||||
"wolf",
|
||||
] as const
|
||||
|
||||
function pick<const T extends readonly string[]>(list: T) {
|
||||
return list[Math.floor(Math.random() * list.length)]
|
||||
}
|
||||
|
||||
function slug(input: string) {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
@@ -134,8 +202,28 @@ export namespace Worktree {
|
||||
.replace(/-+$/, "")
|
||||
}
|
||||
|
||||
function failedRemoves(...chunks: string[]) {
|
||||
return chunks.filter(Boolean).flatMap((chunk) =>
|
||||
function randomName() {
|
||||
return `${pick(ADJECTIVES)}-${pick(NOUNS)}`
|
||||
}
|
||||
|
||||
async function exists(target: string) {
|
||||
return fs
|
||||
.stat(target)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
function outputText(input: Uint8Array | undefined) {
|
||||
if (!input?.length) return ""
|
||||
return new TextDecoder().decode(input).trim()
|
||||
}
|
||||
|
||||
function errorText(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
|
||||
return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
|
||||
}
|
||||
|
||||
function failed(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
|
||||
return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).flatMap((chunk) =>
|
||||
chunk
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
@@ -149,452 +237,436 @@ export namespace Worktree {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effect service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Interface {
|
||||
readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
|
||||
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
|
||||
readonly create: (input?: CreateInput) => Effect.Effect<Info>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
|
||||
readonly reset: (input: ResetInput) => Effect.Effect<boolean>
|
||||
async function prune(root: string, entries: string[]) {
|
||||
const base = await canonical(root)
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const target = await canonical(path.resolve(root, entry))
|
||||
if (target === base) return
|
||||
if (!target.startsWith(`${base}${path.sep}`)) return
|
||||
await fs.rm(target, { recursive: true, force: true }).catch(() => undefined)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Worktree") {}
|
||||
async function sweep(root: string) {
|
||||
const first = await git(["clean", "-ffdx"], { cwd: root })
|
||||
if (first.exitCode === 0) return first
|
||||
|
||||
type GitResult = { code: number; text: string; stderr: string }
|
||||
const entries = failed(first)
|
||||
if (!entries.length) return first
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const fsys = yield* FileSystem.FileSystem
|
||||
const pathSvc = yield* Path.Path
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
await prune(root, entries)
|
||||
return git(["clean", "-ffdx"], { cwd: root })
|
||||
}
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (args: string[], opts?: { cwd?: string }) {
|
||||
const handle = yield* spawner.spawn(
|
||||
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
|
||||
)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch((e) =>
|
||||
Effect.succeed({ code: 1, text: "", stderr: e instanceof Error ? e.message : String(e) } satisfies GitResult),
|
||||
),
|
||||
)
|
||||
async function canonical(input: string) {
|
||||
const abs = path.resolve(input)
|
||||
const real = await fs.realpath(abs).catch(() => abs)
|
||||
const normalized = path.normalize(real)
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
const MAX_NAME_ATTEMPTS = 26
|
||||
const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) {
|
||||
for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
|
||||
const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create()
|
||||
const branch = `opencode/${name}`
|
||||
const directory = pathSvc.join(root, name)
|
||||
async function candidate(root: string, base?: string) {
|
||||
for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
|
||||
const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
|
||||
const branch = `opencode/${name}`
|
||||
const directory = path.join(root, name)
|
||||
|
||||
if (yield* fsys.exists(directory).pipe(Effect.orDie)) continue
|
||||
if (await exists(directory)) continue
|
||||
|
||||
const ref = `refs/heads/${branch}`
|
||||
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree })
|
||||
if (branchCheck.code === 0) continue
|
||||
|
||||
return Info.parse({ name, branch, directory })
|
||||
}
|
||||
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
|
||||
const ref = `refs/heads/${branch}`
|
||||
const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (branchCheck.exitCode === 0) continue
|
||||
|
||||
const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
return Info.parse({ name, branch, directory })
|
||||
}
|
||||
|
||||
const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
|
||||
yield* fsys.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
||||
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
|
||||
}
|
||||
|
||||
const base = name ? slugify(name) : ""
|
||||
return yield* candidate(root, base || undefined)
|
||||
async function runStartCommand(directory: string, cmd: string) {
|
||||
if (process.platform === "win32") {
|
||||
return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true })
|
||||
}
|
||||
return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true })
|
||||
}
|
||||
|
||||
type StartKind = "project" | "worktree"
|
||||
|
||||
async function runStartScript(directory: string, cmd: string, kind: StartKind) {
|
||||
const text = cmd.trim()
|
||||
if (!text) return true
|
||||
|
||||
const ran = await runStartCommand(directory, text)
|
||||
if (ran.code === 0) return true
|
||||
|
||||
log.error("worktree start command failed", {
|
||||
kind,
|
||||
directory,
|
||||
message: errorText(ran),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
async function runStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get())
|
||||
const project = row ? Project.fromRow(row) : undefined
|
||||
const startup = project?.commands?.start?.trim() ?? ""
|
||||
const ok = await runStartScript(directory, startup, "project")
|
||||
if (!ok) return false
|
||||
|
||||
const extra = input.extra ?? ""
|
||||
await runStartScript(directory, extra, "worktree")
|
||||
return true
|
||||
}
|
||||
|
||||
function queueStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) {
|
||||
setTimeout(() => {
|
||||
const start = async () => {
|
||||
await runStartScripts(directory, input)
|
||||
}
|
||||
|
||||
void start().catch((error) => {
|
||||
log.error("worktree start task failed", { directory, error })
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const setup = Effect.fnUntraced(function* (info: Info) {
|
||||
const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (created.code !== 0) {
|
||||
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
|
||||
}
|
||||
export async function makeWorktreeInfo(name?: string): Promise<Info> {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined))
|
||||
})
|
||||
const root = path.join(Global.Path.data, "worktree", Instance.project.id)
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
|
||||
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
||||
const projectID = Instance.project.id
|
||||
const extra = startCommand?.trim()
|
||||
const base = name ? slug(name) : ""
|
||||
return candidate(root, base || undefined)
|
||||
}
|
||||
|
||||
const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
|
||||
if (populated.code !== 0) {
|
||||
const message = populated.stderr || populated.text || "Failed to populate worktree"
|
||||
export async function createFromInfo(info: Info, startCommand?: string) {
|
||||
const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (created.exitCode !== 0) {
|
||||
throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
|
||||
}
|
||||
|
||||
await Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined)
|
||||
|
||||
const projectID = Instance.project.id
|
||||
const extra = startCommand?.trim()
|
||||
|
||||
return () => {
|
||||
const start = async () => {
|
||||
const populated = await git(["reset", "--hard"], { cwd: info.directory })
|
||||
if (populated.exitCode !== 0) {
|
||||
const message = errorText(populated) || "Failed to populate worktree"
|
||||
log.error("worktree checkout failed", { directory: info.directory, message })
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
payload: { type: Event.Failed.type, properties: { message } },
|
||||
payload: {
|
||||
type: Event.Failed.type,
|
||||
properties: {
|
||||
message,
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const booted = yield* Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: info.directory,
|
||||
init: InstanceBootstrap,
|
||||
fn: () => undefined,
|
||||
const booted = await Instance.provide({
|
||||
directory: info.directory,
|
||||
init: InstanceBootstrap,
|
||||
fn: () => undefined,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
payload: {
|
||||
type: Event.Failed.type,
|
||||
properties: {
|
||||
message,
|
||||
},
|
||||
},
|
||||
})
|
||||
return false
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
payload: { type: Event.Failed.type, properties: { message } },
|
||||
})
|
||||
return false
|
||||
}),
|
||||
)
|
||||
if (!booted) return
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
payload: {
|
||||
type: Event.Ready.type,
|
||||
properties: { name: info.name, branch: info.branch },
|
||||
properties: {
|
||||
name: info.name,
|
||||
branch: info.branch,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
yield* runStartScripts(info.directory, { projectID, extra })
|
||||
})
|
||||
|
||||
const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
|
||||
yield* setup(info)
|
||||
yield* boot(info, startCommand)
|
||||
})
|
||||
|
||||
const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
|
||||
const info = yield* makeWorktreeInfo(input?.name)
|
||||
yield* setup(info)
|
||||
yield* boot(info, input?.startCommand).pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
return info
|
||||
})
|
||||
|
||||
const canonical = Effect.fnUntraced(function* (input: string) {
|
||||
const abs = pathSvc.resolve(input)
|
||||
const real = yield* fsys.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
|
||||
const normalized = pathSvc.normalize(real)
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized
|
||||
})
|
||||
|
||||
function parseWorktreeList(text: string) {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
|
||||
if (!line) return acc
|
||||
if (line.startsWith("worktree ")) {
|
||||
acc.push({ path: line.slice("worktree ".length).trim() })
|
||||
return acc
|
||||
}
|
||||
const current = acc[acc.length - 1]
|
||||
if (!current) return acc
|
||||
if (line.startsWith("branch ")) {
|
||||
current.branch = line.slice("branch ".length).trim()
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
await runStartScripts(info.directory, { projectID, extra })
|
||||
}
|
||||
|
||||
const locateWorktree = Effect.fnUntraced(function* (
|
||||
entries: { path?: string; branch?: string }[],
|
||||
directory: string,
|
||||
) {
|
||||
return start().catch((error) => {
|
||||
log.error("worktree start task failed", { directory: info.directory, error })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const create = fn(CreateInput.optional(), async (input) => {
|
||||
const info = await makeWorktreeInfo(input?.name)
|
||||
const bootstrap = await createFromInfo(info, input?.startCommand)
|
||||
// This is needed due to how worktrees currently work in the
|
||||
// desktop app
|
||||
setTimeout(() => {
|
||||
bootstrap()
|
||||
}, 0)
|
||||
return info
|
||||
})
|
||||
|
||||
export const remove = fn(RemoveInput, async (input) => {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
|
||||
const directory = await canonical(input.directory)
|
||||
const locate = async (stdout: Uint8Array | undefined) => {
|
||||
const lines = outputText(stdout)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
|
||||
if (!line) return acc
|
||||
if (line.startsWith("worktree ")) {
|
||||
acc.push({ path: line.slice("worktree ".length).trim() })
|
||||
return acc
|
||||
}
|
||||
const current = acc[acc.length - 1]
|
||||
if (!current) return acc
|
||||
if (line.startsWith("branch ")) {
|
||||
current.branch = line.slice("branch ".length).trim()
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return (async () => {
|
||||
for (const item of entries) {
|
||||
if (!item.path) continue
|
||||
const key = yield* canonical(item.path)
|
||||
const key = await canonical(item.path)
|
||||
if (key === directory) return item
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
function stopFsmonitor(target: string) {
|
||||
return fsys.exists(target).pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
|
||||
)
|
||||
const clean = (target: string) =>
|
||||
fs
|
||||
.rm(target, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
|
||||
})
|
||||
|
||||
const stop = async (target: string) => {
|
||||
if (!(await exists(target))) return
|
||||
await git(["fsmonitor--daemon", "stop"], { cwd: target })
|
||||
}
|
||||
|
||||
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (list.exitCode !== 0) {
|
||||
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
|
||||
}
|
||||
|
||||
const entry = await locate(list.stdout)
|
||||
|
||||
if (!entry?.path) {
|
||||
const directoryExists = await exists(directory)
|
||||
if (directoryExists) {
|
||||
await stop(directory)
|
||||
await clean(directory)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
await stop(entry.path)
|
||||
const removed = await git(["worktree", "remove", "--force", entry.path], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (removed.exitCode !== 0) {
|
||||
const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (next.exitCode !== 0) {
|
||||
throw new RemoveFailedError({
|
||||
message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
|
||||
})
|
||||
}
|
||||
|
||||
function cleanDirectory(target: string) {
|
||||
return Effect.promise(() =>
|
||||
import("fs/promises").then((fsp) =>
|
||||
fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
|
||||
),
|
||||
)
|
||||
const stale = await locate(next.stdout)
|
||||
if (stale?.path) {
|
||||
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
|
||||
}
|
||||
}
|
||||
|
||||
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
await clean(entry.path)
|
||||
|
||||
const directory = yield* canonical(input.directory)
|
||||
const branch = entry.branch?.replace(/^refs\/heads\//, "")
|
||||
if (branch) {
|
||||
const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree })
|
||||
if (deleted.exitCode !== 0) {
|
||||
throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
|
||||
}
|
||||
}
|
||||
|
||||
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (list.code !== 0) {
|
||||
throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const entries = parseWorktreeList(list.text)
|
||||
const entry = yield* locateWorktree(entries, directory)
|
||||
export const reset = fn(ResetInput, async (input) => {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
|
||||
if (!entry?.path) {
|
||||
const directoryExists = yield* fsys.exists(directory).pipe(Effect.orDie)
|
||||
if (directoryExists) {
|
||||
yield* stopFsmonitor(directory)
|
||||
yield* cleanDirectory(directory)
|
||||
}
|
||||
return true
|
||||
}
|
||||
const directory = await canonical(input.directory)
|
||||
const primary = await canonical(Instance.worktree)
|
||||
if (directory === primary) {
|
||||
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
|
||||
}
|
||||
|
||||
yield* stopFsmonitor(entry.path)
|
||||
const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree })
|
||||
if (removed.code !== 0) {
|
||||
const next = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (next.code !== 0) {
|
||||
throw new RemoveFailedError({
|
||||
message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
|
||||
})
|
||||
}
|
||||
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (list.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
|
||||
}
|
||||
|
||||
const stale = yield* locateWorktree(parseWorktreeList(next.text), directory)
|
||||
if (stale?.path) {
|
||||
throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" })
|
||||
}
|
||||
}
|
||||
const lines = outputText(list.stdout)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
|
||||
if (!line) return acc
|
||||
if (line.startsWith("worktree ")) {
|
||||
acc.push({ path: line.slice("worktree ".length).trim() })
|
||||
return acc
|
||||
}
|
||||
const current = acc[acc.length - 1]
|
||||
if (!current) return acc
|
||||
if (line.startsWith("branch ")) {
|
||||
current.branch = line.slice("branch ".length).trim()
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
yield* cleanDirectory(entry.path)
|
||||
const entry = await (async () => {
|
||||
for (const item of entries) {
|
||||
if (!item.path) continue
|
||||
const key = await canonical(item.path)
|
||||
if (key === directory) return item
|
||||
}
|
||||
})()
|
||||
if (!entry?.path) {
|
||||
throw new ResetFailedError({ message: "Worktree not found" })
|
||||
}
|
||||
|
||||
const branch = entry.branch?.replace(/^refs\/heads\//, "")
|
||||
if (branch) {
|
||||
const deleted = yield* git(["branch", "-D", branch], { cwd: Instance.worktree })
|
||||
if (deleted.code !== 0) {
|
||||
throw new RemoveFailedError({
|
||||
message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
|
||||
})
|
||||
}
|
||||
}
|
||||
const remoteList = await git(["remote"], { cwd: Instance.worktree })
|
||||
if (remoteList.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
const remotes = outputText(remoteList.stdout)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const gitExpect = Effect.fnUntraced(function* (
|
||||
args: string[],
|
||||
opts: { cwd: string },
|
||||
error: (r: GitResult) => Error,
|
||||
) {
|
||||
const result = yield* git(args, opts)
|
||||
if (result.code !== 0) throw error(result)
|
||||
return result
|
||||
})
|
||||
const remote = remotes.includes("origin")
|
||||
? "origin"
|
||||
: remotes.length === 1
|
||||
? remotes[0]
|
||||
: remotes.includes("upstream")
|
||||
? "upstream"
|
||||
: ""
|
||||
|
||||
const runStartCommand = Effect.fnUntraced(
|
||||
function* (directory: string, cmd: string) {
|
||||
const [shell, args] = process.platform === "win32" ? ["cmd", ["/c", cmd]] : ["bash", ["-lc", cmd]]
|
||||
const handle = yield* spawner.spawn(
|
||||
ChildProcess.make(shell, args, { cwd: directory, extendEnv: true, stdin: "ignore" }),
|
||||
)
|
||||
// Drain stdout, capture stderr for error reporting
|
||||
const [, stderr] = yield* Effect.all(
|
||||
[Stream.runDrain(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
).pipe(Effect.orDie)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, stderr }
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch(() => Effect.succeed({ code: 1, stderr: "" })),
|
||||
)
|
||||
const remoteHead = remote
|
||||
? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
|
||||
: { exitCode: 1, stdout: undefined, stderr: undefined }
|
||||
|
||||
const runStartScript = Effect.fnUntraced(function* (directory: string, cmd: string, kind: string) {
|
||||
const text = cmd.trim()
|
||||
if (!text) return true
|
||||
const result = yield* runStartCommand(directory, text)
|
||||
if (result.code === 0) return true
|
||||
log.error("worktree start command failed", { kind, directory, message: result.stderr })
|
||||
return false
|
||||
})
|
||||
const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
|
||||
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
|
||||
const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
|
||||
|
||||
const runStartScripts = Effect.fnUntraced(function* (
|
||||
directory: string,
|
||||
input: { projectID: ProjectID; extra?: string },
|
||||
) {
|
||||
const row = yield* Effect.sync(() =>
|
||||
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()),
|
||||
)
|
||||
const project = row ? Project.fromRow(row) : undefined
|
||||
const startup = project?.commands?.start?.trim() ?? ""
|
||||
const ok = yield* runStartScript(directory, startup, "project")
|
||||
if (!ok) return false
|
||||
yield* runStartScript(directory, input.extra ?? "", "worktree")
|
||||
return true
|
||||
})
|
||||
const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
|
||||
|
||||
const prune = Effect.fnUntraced(function* (root: string, entries: string[]) {
|
||||
const base = yield* canonical(root)
|
||||
yield* Effect.forEach(
|
||||
entries,
|
||||
(entry) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* canonical(pathSvc.resolve(root, entry))
|
||||
if (target === base) return
|
||||
if (!target.startsWith(`${base}${pathSvc.sep}`)) return
|
||||
yield* fsys.remove(target, { recursive: true }).pipe(Effect.ignore)
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
})
|
||||
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
|
||||
if (!target) {
|
||||
throw new ResetFailedError({ message: "Default branch not found" })
|
||||
}
|
||||
|
||||
const sweep = Effect.fnUntraced(function* (root: string) {
|
||||
const first = yield* git(["clean", "-ffdx"], { cwd: root })
|
||||
if (first.code === 0) return first
|
||||
if (remoteBranch) {
|
||||
const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
|
||||
if (fetch.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
|
||||
}
|
||||
}
|
||||
|
||||
const entries = failedRemoves(first.stderr, first.text)
|
||||
if (!entries.length) return first
|
||||
if (!entry.path) {
|
||||
throw new ResetFailedError({ message: "Worktree path not found" })
|
||||
}
|
||||
|
||||
yield* prune(root, entries)
|
||||
return yield* git(["clean", "-ffdx"], { cwd: root })
|
||||
})
|
||||
const worktreePath = entry.path
|
||||
|
||||
const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath })
|
||||
if (resetToTarget.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
|
||||
}
|
||||
|
||||
const directory = yield* canonical(input.directory)
|
||||
const primary = yield* canonical(Instance.worktree)
|
||||
if (directory === primary) {
|
||||
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
|
||||
}
|
||||
const clean = await sweep(worktreePath)
|
||||
if (clean.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
|
||||
}
|
||||
|
||||
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (list.code !== 0) {
|
||||
throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
|
||||
}
|
||||
const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath })
|
||||
if (update.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
|
||||
}
|
||||
|
||||
const entry = yield* locateWorktree(parseWorktreeList(list.text), directory)
|
||||
if (!entry?.path) {
|
||||
throw new ResetFailedError({ message: "Worktree not found" })
|
||||
}
|
||||
const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], {
|
||||
cwd: worktreePath,
|
||||
})
|
||||
if (subReset.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
|
||||
}
|
||||
|
||||
const worktreePath = entry.path
|
||||
const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], {
|
||||
cwd: worktreePath,
|
||||
})
|
||||
if (subClean.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
|
||||
}
|
||||
|
||||
const base = yield* Effect.promise(() => Git.defaultBranch(Instance.worktree))
|
||||
if (!base) {
|
||||
throw new ResetFailedError({ message: "Default branch not found" })
|
||||
}
|
||||
const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
|
||||
if (status.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
|
||||
}
|
||||
|
||||
const sep = base.ref.indexOf("/")
|
||||
if (base.ref !== base.name && sep > 0) {
|
||||
const remote = base.ref.slice(0, sep)
|
||||
const branch = base.ref.slice(sep + 1)
|
||||
yield* gitExpect(
|
||||
["fetch", remote, branch],
|
||||
{ cwd: Instance.worktree },
|
||||
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }),
|
||||
)
|
||||
}
|
||||
const dirty = outputText(status.stdout)
|
||||
if (dirty) {
|
||||
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
|
||||
}
|
||||
|
||||
yield* gitExpect(
|
||||
["reset", "--hard", base.ref],
|
||||
{ cwd: worktreePath },
|
||||
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
|
||||
)
|
||||
const projectID = Instance.project.id
|
||||
queueStartScripts(worktreePath, { projectID })
|
||||
|
||||
const cleanResult = yield* sweep(worktreePath)
|
||||
if (cleanResult.code !== 0) {
|
||||
throw new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" })
|
||||
}
|
||||
|
||||
yield* gitExpect(
|
||||
["submodule", "update", "--init", "--recursive", "--force"],
|
||||
{ cwd: worktreePath },
|
||||
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to update submodules" }),
|
||||
)
|
||||
|
||||
yield* gitExpect(
|
||||
["submodule", "foreach", "--recursive", "git", "reset", "--hard"],
|
||||
{ cwd: worktreePath },
|
||||
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset submodules" }),
|
||||
)
|
||||
|
||||
yield* gitExpect(
|
||||
["submodule", "foreach", "--recursive", "git", "clean", "-fdx"],
|
||||
{ cwd: worktreePath },
|
||||
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to clean submodules" }),
|
||||
)
|
||||
|
||||
const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
|
||||
if (status.code !== 0) {
|
||||
throw new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" })
|
||||
}
|
||||
|
||||
if (status.text.trim()) {
|
||||
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
|
||||
}
|
||||
|
||||
yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset })
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultLayer = layer.pipe(
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export async function makeWorktreeInfo(name?: string) {
|
||||
return runPromise((svc) => svc.makeWorktreeInfo(name))
|
||||
}
|
||||
|
||||
export async function createFromInfo(info: Info, startCommand?: string) {
|
||||
return runPromise((svc) => svc.createFromInfo(info, startCommand))
|
||||
}
|
||||
|
||||
export async function create(input?: CreateInput) {
|
||||
return runPromise((svc) => svc.create(input))
|
||||
}
|
||||
|
||||
export async function remove(input: RemoveInput) {
|
||||
return runPromise((svc) => svc.remove(input))
|
||||
}
|
||||
|
||||
export async function reset(input: ResetInput) {
|
||||
return runPromise((svc) => svc.reset(input))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,8 +60,6 @@ function toolEvent(
|
||||
const payload: EventMessagePartUpdated = {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: `part_${opts.callID}`,
|
||||
sessionID: sessionId,
|
||||
|
||||
@@ -73,6 +73,7 @@ test("explore agent denies edit and write", async () => {
|
||||
expect(explore?.mode).toBe("subagent")
|
||||
expect(evalPerm(explore, "edit")).toBe("deny")
|
||||
expect(evalPerm(explore, "write")).toBe("deny")
|
||||
expect(evalPerm(explore, "todoread")).toBe("deny")
|
||||
expect(evalPerm(explore, "todowrite")).toBe("deny")
|
||||
},
|
||||
})
|
||||
@@ -101,6 +102,7 @@ test("general agent denies todo tools", async () => {
|
||||
expect(general).toBeDefined()
|
||||
expect(general?.mode).toBe("subagent")
|
||||
expect(general?.hidden).toBeUndefined()
|
||||
expect(evalPerm(general, "todoread")).toBe("deny")
|
||||
expect(evalPerm(general, "todowrite")).toBe("deny")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1400,6 +1400,7 @@ test("permission config preserves key order", async () => {
|
||||
external_directory: "ask",
|
||||
read: "allow",
|
||||
todowrite: "allow",
|
||||
todoread: "allow",
|
||||
"thoughts_*": "allow",
|
||||
"reasoning_model_*": "allow",
|
||||
"tools_*": "allow",
|
||||
@@ -1420,6 +1421,7 @@ test("permission config preserves key order", async () => {
|
||||
"external_directory",
|
||||
"read",
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"thoughts_*",
|
||||
"reasoning_model_*",
|
||||
"tools_*",
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { ManagedRuntime } from "effect"
|
||||
import { Git } from "../../src/git"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
|
||||
|
||||
async function withGit<T>(body: (rt: ManagedRuntime.ManagedRuntime<Git.Service, never>) => Promise<T>) {
|
||||
const rt = ManagedRuntime.make(Git.defaultLayer)
|
||||
try {
|
||||
return await body(rt)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
describe("Git", () => {
|
||||
test("branch() returns current branch name", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
|
||||
expect(branch).toBeDefined()
|
||||
expect(typeof branch).toBe("string")
|
||||
})
|
||||
})
|
||||
|
||||
test("branch() returns undefined for non-git directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
|
||||
expect(branch).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
test("branch() returns undefined for detached HEAD", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const hash = (await $`git rev-parse HEAD`.cwd(tmp.path).quiet().text()).trim()
|
||||
await $`git checkout --detach ${hash}`.cwd(tmp.path).quiet()
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
|
||||
expect(branch).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
test("defaultBranch() uses init.defaultBranch when available", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M trunk`.cwd(tmp.path).quiet()
|
||||
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const branch = await rt.runPromise(Git.Service.use((git) => git.defaultBranch(tmp.path)))
|
||||
expect(branch?.name).toBe("trunk")
|
||||
expect(branch?.ref).toBe("trunk")
|
||||
})
|
||||
})
|
||||
|
||||
test("status() handles special filenames", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const status = await rt.runPromise(Git.Service.use((git) => git.status(tmp.path)))
|
||||
expect(status).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: weird,
|
||||
status: "added",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("diff(), stats(), and mergeBase() parse tracked changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const [base, diff, stats] = await Promise.all([
|
||||
rt.runPromise(Git.Service.use((git) => git.mergeBase(tmp.path, "main"))),
|
||||
rt.runPromise(Git.Service.use((git) => git.diff(tmp.path, "HEAD"))),
|
||||
rt.runPromise(Git.Service.use((git) => git.stats(tmp.path, "HEAD"))),
|
||||
])
|
||||
|
||||
expect(base).toBeTruthy()
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: weird,
|
||||
status: "modified",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(stats).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: weird,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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]))
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const text = await rt.runPromise(Git.Service.use((git) => git.show(tmp.path, "HEAD", "bin.dat")))
|
||||
expect(text).toBe("")
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user