mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 08:26:25 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f1361a149 | ||
|
|
2516d8e976 |
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
bun-version: 1.4.1
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
id: build
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.2
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.1
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
|
||||
@@ -183,7 +183,7 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||
|
||||
@@ -695,8 +695,6 @@
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -1067,7 +1065,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.2",
|
||||
"bun-types": "1.4.1",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
@@ -3487,7 +3485,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
|
||||
@@ -235,7 +235,6 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
|
||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||
const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL")
|
||||
|
||||
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
|
||||
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
|
||||
@@ -264,7 +263,6 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
ENTERPRISE_SALES_INBOX_EMAIL,
|
||||
SALESFORCE_CLIENT_ID,
|
||||
SALESFORCE_CLIENT_SECRET,
|
||||
SALESFORCE_INSTANCE_URL,
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-6VoxH/BowtAOYdhGqD7ZkFJv+h+/onFmuNZhvBxoGAI=",
|
||||
"aarch64-linux": "sha256-Va77URDCTAkvMQ0g7huFgp1iwb5C6+idSrlLOaFNVB0=",
|
||||
"aarch64-darwin": "sha256-IqCsKuiuuAmK4Z3oOfJ9kX4b4CpCmj6GeTKhXOewc7E=",
|
||||
"x86_64-darwin": "sha256-LXOxHzh6SS63WkfKvwBz+NylfvbcCi+rmXIRmpwu/oE="
|
||||
"x86_64-linux": "sha256-J7xJ1+x+f13zso+UMP1YYzkjFM0LiHZURmf8HhO3X4M=",
|
||||
"aarch64-linux": "sha256-CF7BiObbwjFT4BV5FUqB535fqoPuXuExKpERcev/Re0=",
|
||||
"aarch64-darwin": "sha256-ts3zgHiPeQcrehST1ikr+BNwlF+3JDn0/IUeJSAOB3o=",
|
||||
"x86_64-darwin": "sha256-awKiMBuDMrapw3Lrm39TbE+ILJZ1P8R8sqYJEWapSf8="
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
"packageManager": "bun@1.4.1",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
@@ -159,7 +159,7 @@
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.2",
|
||||
"bun-types": "1.4.1",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
|
||||
@@ -113,8 +113,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
responseID = created
|
||||
return { type: "frame", frame }
|
||||
}
|
||||
// Keepalives and provider notifications carry no response state and may precede response.created.
|
||||
if (!event.type.startsWith("response.")) return { type: "frame", frame }
|
||||
// Keepalives carry no response state and may arrive before response.created.
|
||||
if (event.type === "keepalive") return { type: "frame", frame }
|
||||
if (!responseID)
|
||||
return yield* ProviderShared.eventError(
|
||||
options.id,
|
||||
|
||||
@@ -526,7 +526,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("tolerates keepalive and provider notifications before response.created", () =>
|
||||
it.effect("tolerates keepalive frames before response.created", () =>
|
||||
Effect.gen(function* () {
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
@@ -534,7 +534,6 @@ describe("OpenAI Responses route", () => {
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
|
||||
ProviderShared.encodeJson({ type: "codex.rate_limits" }),
|
||||
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.completed",
|
||||
|
||||
@@ -86,12 +86,7 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
}
|
||||
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||
}
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory: current.directory,
|
||||
project: { id: current.projectID, directory: current.directory, canonical: current.directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory: current.directory }])
|
||||
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
|
||||
@@ -490,24 +490,20 @@ async function openDraft(
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path === "/api/worktree") {
|
||||
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
if (path === `/api/worktree/${projectID}`) {
|
||||
calls.push("worktree")
|
||||
worktreeRequests.push(request.postDataJSON())
|
||||
}
|
||||
if (path === "/api/session") calls.push("session")
|
||||
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
|
||||
})
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
// Keep the real HTTP response pending until the test has checked the preview.
|
||||
const response = await worktree.promise
|
||||
if (response.status === 200) project.sandboxes.push(workspace)
|
||||
await route.fulfill({ ...response, headers })
|
||||
},
|
||||
)
|
||||
await page.route(`**/api/worktree/${projectID}`, async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
// Keep the real HTTP response pending until the test has checked the preview.
|
||||
const response = await worktree.promise
|
||||
if (response.status === 200) project.sandboxes.push(workspace)
|
||||
await route.fulfill({ ...response, headers })
|
||||
})
|
||||
await page.route("**/api/session", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const body: Record<string, unknown> = route.request().postDataJSON()
|
||||
|
||||
@@ -396,12 +396,7 @@ async function mockServers(
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
|
||||
return json(route, { data: [] })
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory,
|
||||
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
|
||||
if (url.pathname === "/api/location") return json(route, { directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
||||
|
||||
@@ -87,24 +87,16 @@ test("recording a new session shortcut stays in settings until recording finishe
|
||||
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
|
||||
const inventory = Promise.withResolvers<void>()
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
await inventory.promise
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
await page.route("**/api/worktree/*", async (route) => {
|
||||
await inventory.promise
|
||||
await route.fallback()
|
||||
})
|
||||
await page.route("**/api/session?*", async (route) => {
|
||||
if (new URL(route.request().url()).searchParams.has("directory")) await sessions.promise
|
||||
await route.fallback()
|
||||
})
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const requested = page.waitForRequest(
|
||||
(request) =>
|
||||
new URL(request.url()).pathname === "/api/worktree" &&
|
||||
new URL(request.url()).searchParams.get("location[directory]") === directory &&
|
||||
request.method() === "GET",
|
||||
)
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname.startsWith("/api/worktree/"))
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await requested
|
||||
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
|
||||
@@ -118,57 +110,16 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
|
||||
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
|
||||
|
||||
const refresh = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
await refresh.promise
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
await page.route("**/api/worktree/*", async (route) => {
|
||||
await refresh.promise
|
||||
await route.fallback()
|
||||
})
|
||||
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
|
||||
refresh.resolve()
|
||||
})
|
||||
|
||||
test("worktree deletion sends the project location separately from the target", async ({ page }) => {
|
||||
const removed = new Set<string>()
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{ directory },
|
||||
...sandboxes.filter((item) => !removed.has(item)).map((directory) => ({ directory, strategy: "git" })),
|
||||
],
|
||||
})
|
||||
}
|
||||
if (route.request().method() === "DELETE") {
|
||||
removed.add(route.request().postDataJSON().directory)
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fallback()
|
||||
},
|
||||
)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
|
||||
await settings.getByRole("button", { name: 'Delete worktree "workspace-1"?', exact: true }).click()
|
||||
const confirmation = page.getByRole("dialog", { name: "Delete worktree", exact: true })
|
||||
const remove = confirmation.getByRole("button", { name: "Delete worktree", exact: true })
|
||||
await expect(remove).toBeEnabled()
|
||||
const deleting = page.waitForRequest(
|
||||
(request) => new URL(request.url()).pathname === "/api/worktree" && request.method() === "DELETE",
|
||||
)
|
||||
await remove.click()
|
||||
const request = await deleting
|
||||
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
expect(request.postDataJSON()).toEqual({ directory: sandboxes[0], force: true })
|
||||
await expect(settings.getByText(sandboxes[0], { exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByText("11 worktrees", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("extensions opens without waiting for MCPs", async ({ page }) => {
|
||||
const mcps = Promise.withResolvers<void>()
|
||||
await page.route("**/api/mcp", async (route) => {
|
||||
|
||||
@@ -225,81 +225,13 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(resized.x - 200, resized.y + resized.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect(sidebar).toHaveCSS("width", "140px")
|
||||
await expect(sidebar).toHaveCSS("width", "130px")
|
||||
|
||||
await tabB.click()
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect(tabB).toBeVisible()
|
||||
})
|
||||
|
||||
for (const count of [0, 26]) {
|
||||
test(`vertical navigation labels and icons use the available width with ${count} tabs`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, directory, count }) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
appearance: { tabLayout: "vertical" },
|
||||
keybinds: { "home.toggle": "alt+home", "tab.new": "ctrl+shift+n" },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
type: "draft",
|
||||
server,
|
||||
directory,
|
||||
draftID: `draft_navigation_${index}`,
|
||||
})),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ server, directory: sessionA.directory, count },
|
||||
)
|
||||
await page.goto("/")
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
await expect(sidebar).toHaveCSS("width", "260px")
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(count)
|
||||
for (const width of [260, 180, 140]) {
|
||||
if (width !== 260) {
|
||||
const handle = await sidebar.locator('[data-component="resize-handle"]').boundingBox()
|
||||
const bounds = await sidebar.boundingBox()
|
||||
if (!handle || !bounds) throw new Error("vertical tab sidebar has no bounding box")
|
||||
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(handle.x + handle.width / 2 + width - bounds.width, handle.y + handle.height / 2)
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect(sidebar).toHaveCSS("width", `${width}px`)
|
||||
await testInfo.attach(`navigation-${count}-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
for (const name of ["Home", "New session"]) {
|
||||
const button = sidebar.getByRole("button", { name, exact: true })
|
||||
const label = button.getByText(name, { exact: true })
|
||||
await expect(label).toBeVisible()
|
||||
await expect
|
||||
.poll(() => label.evaluate((element) => element.scrollWidth - element.clientWidth), { message: name })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
|
||||
await button.hover()
|
||||
await expect(button.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
|
||||
await expect
|
||||
.poll(() => button.evaluate((element) => element.scrollWidth - element.clientWidth))
|
||||
.toBeLessThanOrEqual(1)
|
||||
await page.getByRole("main").hover()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
@@ -427,9 +359,9 @@ for (const profile of [
|
||||
const hint = button.locator('span[aria-hidden="true"]')
|
||||
await expect(hint).toHaveText(row.shortcut)
|
||||
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
|
||||
await expect(hint).toBeHidden()
|
||||
await expect(hint).toHaveCSS("opacity", "0")
|
||||
await button.hover()
|
||||
await expect(hint).toBeVisible()
|
||||
await expect(hint).toHaveCSS("opacity", "1")
|
||||
await expect
|
||||
.poll(() =>
|
||||
hint.evaluate((element) => {
|
||||
@@ -441,7 +373,7 @@ for (const profile of [
|
||||
)
|
||||
.toBeCloseTo(8, 1)
|
||||
await page.getByRole("main").hover()
|
||||
await expect(hint).toBeHidden()
|
||||
await expect(hint).toHaveCSS("opacity", "0")
|
||||
}
|
||||
|
||||
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
|
||||
@@ -449,10 +381,10 @@ for (const profile of [
|
||||
await home.focus()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(newSession).toBeFocused()
|
||||
await expect(newSession.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Shift+Tab")
|
||||
await expect(home).toBeFocused()
|
||||
await expect(home.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -628,12 +560,7 @@ async function mockServer(page: Page) {
|
||||
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory: sessionA.directory,
|
||||
project: { id: sessionA.projectID, directory: sessionA.directory, canonical: sessionA.directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory: sessionA.directory }])
|
||||
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: sessionA.directory },
|
||||
|
||||
@@ -97,9 +97,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
|
||||
const refreshed = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === "/api/worktree" &&
|
||||
new URL(response.url()).searchParams.get("location[directory]") === root &&
|
||||
response.request().method() === "GET",
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
)
|
||||
view.worktrees.push({ directory: workspace, strategy: "git" })
|
||||
view.events.push({
|
||||
@@ -201,13 +199,10 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
// Keep authoritative inventory independent of the raw project's empty sandboxes.
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
(route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
|
||||
},
|
||||
)
|
||||
await page.route(`**/api/worktree/${projectID}`, (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
if (draft)
|
||||
await page.addInitScript(
|
||||
({ root, server }) => {
|
||||
@@ -227,9 +222,7 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
|
||||
)
|
||||
const loaded = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === "/api/worktree" &&
|
||||
new URL(response.url()).searchParams.get("location[directory]") === root &&
|
||||
response.request().method() === "GET",
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
)
|
||||
await page.goto(
|
||||
draft ? "/new-session?draftId=draft_workspace_accent" : `/server/${base64Encode(server)}/session/${sessionID}`,
|
||||
|
||||
@@ -71,23 +71,27 @@ const Group = HttpApiGroup.make("mock")
|
||||
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
|
||||
HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktreeCreate", "/api/worktree", {
|
||||
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
payload: JsonPayload,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree", {
|
||||
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/refresh", {
|
||||
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
|
||||
params: { projectID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
|
||||
[data-color-scheme="dark"] [data-component="session-composer-dock"] [data-component="composer"],
|
||||
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@@ -34,14 +34,6 @@ export function createHomeController() {
|
||||
const conn = list[0]
|
||||
if (conn) setSelection({ server: ServerConnection.key(conn) })
|
||||
})
|
||||
createEffect(() => {
|
||||
const ctx = focusedServerCtx()
|
||||
const id = selectedProject()?.id
|
||||
if (!ctx || !id || ctx.sdk.connection.status() !== "connected") return
|
||||
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
|
||||
const root = ctx.sync.data.project.find((project) => project.id === id)?.worktree
|
||||
if (root) void ctx.sync.worktrees.load(root)
|
||||
})
|
||||
|
||||
function setSelection(next: HomeProjectSelection) {
|
||||
layout.home.setSelection(next)
|
||||
|
||||
@@ -27,7 +27,7 @@ import { sessionLabel, sessionTitle } from "@/session/title"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { archiveHomeSession } from "./archive"
|
||||
import type { HomeController } from "../model"
|
||||
import { buildHomeSessionRecords, homeProjectForSession, type HomeSessionRecord } from "./records"
|
||||
import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
|
||||
|
||||
export type { HomeSessionRecord } from "./records"
|
||||
|
||||
@@ -270,7 +270,14 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
},
|
||||
create: home.project.openNewSession,
|
||||
open: (session: SessionInfo, options?: OpenSessionOptions) => {
|
||||
const project = homeProjectForSession(session, home.project.list())
|
||||
const directoryKey = pathKey(session.location.directory)
|
||||
const project = home.project
|
||||
.list()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directoryKey ||
|
||||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
|
||||
)
|
||||
const conn = home.server.focused()
|
||||
if (!conn) return
|
||||
const connKey = ServerConnection.key(conn)
|
||||
|
||||
@@ -36,26 +36,4 @@ describe("buildHomeSessionRecords", () => {
|
||||
|
||||
expect(records.map((record) => record.session.id)).toEqual(["a"])
|
||||
})
|
||||
|
||||
test("labels a worktree session with its project before that project's inventory has loaded", () => {
|
||||
const records = buildHomeSessionRecords({
|
||||
sessions: () => [session("w", "/repo/a/.worktrees/feature", "project-a")],
|
||||
projectDirectories: () => undefined,
|
||||
projects: () => [{ ...opened, name: "Project A" }],
|
||||
})
|
||||
|
||||
expect(records[0]?.project).toMatchObject({ id: "project-a", worktree: "/repo/a" })
|
||||
expect(records[0]?.projectName).toBe("Project A")
|
||||
})
|
||||
|
||||
test("prefers the added project whose directory matches over a sibling entry with the same ID", () => {
|
||||
const nested = { id: "project-a", worktree: "/repo/a/packages/app", expanded: true } as LocalProject
|
||||
const records = buildHomeSessionRecords({
|
||||
sessions: () => [session("n", "/repo/a/packages/app", "project-a")],
|
||||
projectDirectories: () => undefined,
|
||||
projects: () => [opened, nested],
|
||||
})
|
||||
|
||||
expect(records[0]?.project.worktree).toBe("/repo/a/packages/app")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,13 @@ export function buildHomeSessionRecords(input: {
|
||||
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||
.sort(compareSessionTime)
|
||||
.map((session) => {
|
||||
const project = homeProjectForSession(session, input.projects()) ?? {
|
||||
const directory = pathKey(session.location.directory)
|
||||
const project = input
|
||||
.projects()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? {
|
||||
id: session.projectID,
|
||||
worktree: session.location.directory,
|
||||
expanded: false,
|
||||
@@ -30,18 +36,3 @@ export function buildHomeSessionRecords(input: {
|
||||
return { session, project, projectName: displayName(project) }
|
||||
})
|
||||
}
|
||||
|
||||
// Worktree inventories load on demand, so a worktree session may not match any directory yet;
|
||||
// the session's project ID still identifies its added project.
|
||||
export function homeProjectForSession<T extends { id?: string; worktree: string; sandboxes?: readonly string[] }>(
|
||||
session: SessionInfo,
|
||||
projects: readonly T[],
|
||||
) {
|
||||
const directory = pathKey(session.location.directory)
|
||||
return (
|
||||
projects.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? projects.find((item) => item.id === session.projectID)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { popularProviders } from "./order"
|
||||
|
||||
test("lists OpenCode Go before Zen and other popular providers", () => {
|
||||
expect(popularProviders.slice(0, 2)).toEqual(["opencode-go", "opencode"])
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
export const popularProviders = [
|
||||
"opencode-go",
|
||||
"opencode",
|
||||
"opencode-go",
|
||||
"anthropic",
|
||||
"github-copilot",
|
||||
"openai",
|
||||
|
||||
@@ -110,7 +110,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
active: undefined as string | undefined,
|
||||
connecting: undefined as string | undefined,
|
||||
})
|
||||
const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"]
|
||||
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
|
||||
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
|
||||
const all = createMemo(() => {
|
||||
language.locale()
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ModelTooltip } from "./tooltip"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
|
||||
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
|
||||
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
|
||||
|
||||
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
||||
|
||||
@@ -676,7 +676,7 @@ export const dict = {
|
||||
"session.error.incompatible.description":
|
||||
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
|
||||
"session.background.moveTasks": "Move {{tasks}} to background",
|
||||
"session.background.moveRunning": "Move running work to background",
|
||||
"session.background.moveRunning": "Move to background",
|
||||
"session.background.inBackground": "Running {{tasks}} in background",
|
||||
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
|
||||
"session.background.running": "Running work in background",
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createApiForServer, type ServerApi } from "@/runtime/server/api"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "./registry"
|
||||
import { createRefCountMap } from "@/runtime/server/refcount"
|
||||
import { createRequestQueue } from "@/runtime/server/request-queue"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { useServer } from "./current"
|
||||
|
||||
@@ -115,9 +114,8 @@ export function createServerTransport(input: { http: ServerConnection.HttpBase;
|
||||
readonly api: ServerApi
|
||||
readonly pty: ReturnType<typeof createPtyClient>
|
||||
} {
|
||||
const queue = createRequestQueue({ fetch: input.fetch ?? globalThis.fetch })
|
||||
const build = (http: ServerConnection.HttpBase) => {
|
||||
const api = createApiForServer({ server: http, fetch: queue.fetch })
|
||||
const api = createApiForServer({ server: http, fetch: input.fetch })
|
||||
return { http, api, pty: createPtyClient(api, { url: http.url }) }
|
||||
}
|
||||
const state = { current: build(input.http) }
|
||||
|
||||
@@ -6,7 +6,9 @@ import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import type { ServerSync } from "@/runtime/server/sync"
|
||||
import { worktreeInventoryKey } from "@/workspaces/inventory"
|
||||
|
||||
type ProjectApi = ServerApi["project"]
|
||||
type WorktreeApi = ServerApi["worktree"]
|
||||
|
||||
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
|
||||
const api = OpenCode.make({
|
||||
@@ -21,6 +23,7 @@ test("bootstraps projects through the native store setter and preserves subseque
|
||||
})
|
||||
if (url.pathname === "/api/project")
|
||||
return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
|
||||
if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }])
|
||||
throw new Error(`Unexpected request: ${url.pathname}`)
|
||||
},
|
||||
{ preconnect() {} },
|
||||
@@ -47,18 +50,6 @@ test("bootstraps projects through the native store setter and preserves subseque
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
|
||||
expect(store.config).toEqual({})
|
||||
|
||||
// A refetch keeps the inventory a view already loaded for this project.
|
||||
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "/repo/"), [
|
||||
{ directory: "/repo" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
])
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project[0]?.sandboxes).toEqual(["/repo/feature"])
|
||||
expect(store.project[0]?.worktrees).toEqual([
|
||||
{ directory: "/repo" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
])
|
||||
} finally {
|
||||
queryClient.clear()
|
||||
}
|
||||
@@ -88,39 +79,58 @@ describe("query keys", () => {
|
||||
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
||||
})
|
||||
|
||||
test("loads project metadata without enumerating any project's worktrees", async () => {
|
||||
const requests: string[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(new Request(input, init).url)
|
||||
requests.push(url.pathname)
|
||||
if (url.pathname !== "/api/project") throw new Error(`Unexpected request: ${url}`)
|
||||
return Response.json([
|
||||
...Array.from({ length: 300 }, (_, index) => ({
|
||||
id: `historical-${index.toString().padStart(3, "0")}`,
|
||||
canonical: `/history/${index}`,
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
})),
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: ["/a/legacy"] },
|
||||
{ id: "test", canonical: "/tmp/opencode-test-1", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
])
|
||||
},
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
test("loads projects from the current endpoint", async () => {
|
||||
const calls: string[] = []
|
||||
const projects = {
|
||||
list: async () => [
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
],
|
||||
} as unknown as ProjectApi
|
||||
const worktrees = {
|
||||
list: async ({ projectID }: { projectID: string }) => {
|
||||
calls.push(projectID)
|
||||
return [
|
||||
{ directory: `/${projectID}` },
|
||||
{ directory: `/${projectID}/clone` },
|
||||
{ directory: `/${projectID}/copy`, strategy: "git" },
|
||||
]
|
||||
},
|
||||
} as unknown as WorktreeApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project))
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
|
||||
|
||||
expect(requests).toEqual(["/api/project"])
|
||||
expect(result).toHaveLength(302)
|
||||
expect(result.slice(0, 2)).toMatchObject([
|
||||
{ id: "a", worktree: "/a", sandboxes: ["/a/legacy"], worktrees: [{ directory: "/a" }] },
|
||||
{ id: "b", worktree: "/b", sandboxes: [], worktrees: [{ directory: "/b" }] },
|
||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||
expect(result.map((project) => project.sandboxes)).toEqual([
|
||||
["/a/clone", "/a/copy"],
|
||||
["/b/clone", "/b/copy"],
|
||||
])
|
||||
expect(result.map((project) => project.worktrees)).toEqual([
|
||||
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
|
||||
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
|
||||
])
|
||||
expect(calls.toSorted()).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("keeps projects whose directory inventory cannot load", async () => {
|
||||
const projects = {
|
||||
list: async () => [
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
],
|
||||
} as unknown as ProjectApi
|
||||
const worktrees = {
|
||||
list: async ({ projectID }: { projectID: string }) => {
|
||||
if (projectID === "b") throw new Error("unavailable")
|
||||
return [{ directory: "/a/copy", strategy: "git" as const }]
|
||||
},
|
||||
} as unknown as WorktreeApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
|
||||
|
||||
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
|
||||
{ id: "a", sandboxes: ["/a/copy"] },
|
||||
{ id: "b", sandboxes: [] },
|
||||
])
|
||||
expect(result.some((project) => project.id === "test")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,8 @@ import { cmp, normalizeProjectInfo } from "./utils"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { withWorktreeInventory, worktreeInventoryKey } from "@/workspaces/inventory"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import { sameDirectory } from "@/workspaces/paths"
|
||||
|
||||
type GlobalStore = {
|
||||
path: Path
|
||||
@@ -63,21 +64,42 @@ type ProjectApi = {
|
||||
readonly list: () => Promise<ProjectListOutput>
|
||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||
}
|
||||
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||
|
||||
// Metadata only. Worktree inventories load per project when a view shows it (see workspaces/inventory).
|
||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi) =>
|
||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "project"],
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
projects.list().then((items) =>
|
||||
items
|
||||
.filter((project) => !!project?.id)
|
||||
.map(normalizeProjectInfo)
|
||||
projects.list().then(async (items) => {
|
||||
return (
|
||||
await Promise.all(
|
||||
items
|
||||
.filter((project) => !!project?.id)
|
||||
.map(async (project) => {
|
||||
const directories = await worktrees
|
||||
.list({ projectID: project.id })
|
||||
.catch(() => [
|
||||
{ directory: project.canonical },
|
||||
...(project.sandboxes ?? [])
|
||||
.filter((directory) => !sameDirectory(project.canonical, directory))
|
||||
.map((directory) => ({ directory })),
|
||||
])
|
||||
return normalizeProjectInfo({
|
||||
...project,
|
||||
sandboxes: directories
|
||||
.map((item) => item.directory)
|
||||
.filter((directory) => !sameDirectory(project.canonical, directory)),
|
||||
worktrees: directories,
|
||||
})
|
||||
}),
|
||||
)
|
||||
)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
),
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -85,6 +107,7 @@ export async function bootstrapGlobal(input: {
|
||||
serverAPI: {
|
||||
readonly location: LocationApi
|
||||
readonly project: ProjectApi
|
||||
readonly worktree: WorktreeApi
|
||||
}
|
||||
scope: ServerScope
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
@@ -94,17 +117,9 @@ export async function bootstrapGlobal(input: {
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)).then((data) =>
|
||||
input.setGlobalStore(
|
||||
"project",
|
||||
data.map((project) =>
|
||||
withWorktreeInventory(
|
||||
project,
|
||||
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.worktree)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
|
||||
.then((data) => input.setGlobalStore("project", data)),
|
||||
]
|
||||
await runAll(slow)
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRequestQueue } from "./request-queue"
|
||||
|
||||
function setup(input?: { limit?: number; stallMs?: number }) {
|
||||
const pending: Array<{ url: string; resolve: () => void }> = []
|
||||
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
|
||||
let clock = 0
|
||||
const queue = createRequestQueue({
|
||||
limit: input?.limit ?? 2,
|
||||
stallMs: input?.stallMs,
|
||||
now: () => clock,
|
||||
log: (message, data) => logs.push({ message, data }),
|
||||
fetch: Object.assign(
|
||||
(resource: RequestInfo | URL) =>
|
||||
new Promise<Response>((resolve) => {
|
||||
pending.push({ url: new Request(resource).url, resolve: () => resolve(new Response("ok")) })
|
||||
}),
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
return { queue, pending, logs, settle, tick: (ms: number) => (clock += ms) }
|
||||
}
|
||||
|
||||
describe("createRequestQueue", () => {
|
||||
test("caps concurrent requests and starts queued ones as slots free up", async () => {
|
||||
const input = setup()
|
||||
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b"])
|
||||
expect(input.queue.queued()).toBe(1)
|
||||
input.pending[0]!.resolve()
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b", "/api/c"])
|
||||
input.pending.forEach((item) => item.resolve())
|
||||
await Promise.all(responses)
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("never counts the event stream against the budget", async () => {
|
||||
const input = setup({ limit: 1 })
|
||||
void input.queue.fetch("http://server/api/session")
|
||||
void input.queue.fetch("http://server/api/event")
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname).toSorted()).toEqual(["/api/event", "/api/session"])
|
||||
expect(input.queue.inflight()).toBe(1)
|
||||
})
|
||||
|
||||
test("aborted requests leave the queue without being sent", async () => {
|
||||
const input = setup({ limit: 1 })
|
||||
const controller = new AbortController()
|
||||
void input.queue.fetch("http://server/api/first")
|
||||
const aborted = input.queue.fetch("http://server/api/second", { signal: controller.signal })
|
||||
controller.abort()
|
||||
await input.settle()
|
||||
input.pending[0]!.resolve()
|
||||
await expect(aborted).rejects.toBeInstanceOf(DOMException)
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/first"])
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("a burst that drains promptly is not thrashing", async () => {
|
||||
const input = setup({ stallMs: 5 })
|
||||
const responses = Array.from({ length: 12 }, (_, index) => input.queue.fetch(`http://server/api/${index}`))
|
||||
await input.settle()
|
||||
expect(input.queue.queued()).toBe(10)
|
||||
// Drain two at a time before the stall threshold elapses.
|
||||
for (let round = 0; round < 6; round++) {
|
||||
input.pending.splice(0).forEach((item) => item.resolve())
|
||||
await input.settle()
|
||||
}
|
||||
await Promise.all(responses)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toEqual([])
|
||||
})
|
||||
|
||||
test("logs what is in flight and queued once per burst after requests stall", async () => {
|
||||
const input = setup({ stallMs: 5 })
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa").catch(() => undefined)
|
||||
input.tick(50)
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fb").catch(() => undefined)
|
||||
input.tick(50)
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
|
||||
input.tick(100)
|
||||
input.queue.fetch("http://server/api/health").catch(() => undefined)
|
||||
expect(input.logs).toEqual([])
|
||||
input.tick(2_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toEqual([
|
||||
{
|
||||
message: "server thrashing detected",
|
||||
data: {
|
||||
limit: 2,
|
||||
inflight: [
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fa", ms: 2_200 },
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fb", ms: 2_150 },
|
||||
],
|
||||
queued: [
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
|
||||
{ method: "GET", url: "http://server/api/health", ms: 2_000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
// Still stalled within the rate limit: no repeat.
|
||||
input.tick(2_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toHaveLength(1)
|
||||
input.tick(10_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
type Entry = { method: string; url: string; at: number }
|
||||
|
||||
// Chromium allows six connections per origin. The event stream holds one for the life of the
|
||||
// connection and health probes use their own fetch, so the app's API calls stay below that or
|
||||
// a burst stalls probes and user actions inside the browser where nothing can observe it.
|
||||
export const requestQueueLimit = 4
|
||||
|
||||
// A mount legitimately fires a dozen requests at once; only a request that has waited this long
|
||||
// for a slot indicates the server is not keeping up.
|
||||
export const requestStallMs = 2_000
|
||||
|
||||
export function createRequestQueue(input: {
|
||||
fetch: typeof globalThis.fetch
|
||||
limit?: number
|
||||
stallMs?: number
|
||||
log?: (message: string, data: Record<string, unknown>) => void
|
||||
now?: () => number
|
||||
}) {
|
||||
const limit = input.limit ?? requestQueueLimit
|
||||
const stallMs = input.stallMs ?? requestStallMs
|
||||
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
|
||||
const base = input.fetch
|
||||
const now = input.now ?? Date.now
|
||||
const log = input.log ?? ((message, data) => console.warn(`[server-request-queue] ${message}`, data))
|
||||
const inflight = new Set<Entry>()
|
||||
const waiting: Array<{ entry: Entry; start: () => void }> = []
|
||||
let warned = -Infinity
|
||||
let watcher: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const describe = (entry: Entry) => ({ method: entry.method, url: entry.url, ms: now() - entry.at })
|
||||
// Debug exports include the console, so list what the server is busy with while requests wait.
|
||||
const watch = () => {
|
||||
watcher = undefined
|
||||
const oldest = waiting[0]?.entry
|
||||
if (!oldest) return
|
||||
if (now() - oldest.at >= stallMs && now() - warned >= 10_000) {
|
||||
warned = now()
|
||||
log("server thrashing detected", {
|
||||
limit,
|
||||
inflight: [...inflight].map(describe),
|
||||
queued: waiting.map((item) => describe(item.entry)),
|
||||
})
|
||||
}
|
||||
watcher = setTimeout(watch, stallMs)
|
||||
}
|
||||
const release = (entry: Entry) => {
|
||||
inflight.delete(entry)
|
||||
waiting.shift()?.start()
|
||||
}
|
||||
const acquire = (entry: Entry) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const start = () => {
|
||||
entry.at = now()
|
||||
inflight.add(entry)
|
||||
resolve()
|
||||
}
|
||||
if (inflight.size < limit) return start()
|
||||
waiting.push({ entry, start })
|
||||
watcher ??= setTimeout(watch, stallMs)
|
||||
})
|
||||
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (resource: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(resource, init)
|
||||
// The event stream is long-lived; never count it against the request budget.
|
||||
if (new URL(request.url).pathname === "/api/event") return base(request)
|
||||
const entry = { method: request.method, url: request.url, at: now() }
|
||||
await acquire(entry)
|
||||
if (request.signal.aborted) {
|
||||
release(entry)
|
||||
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
|
||||
}
|
||||
return base(request).finally(() => release(entry))
|
||||
},
|
||||
// Bun's fetch type carries preconnect; the browser never calls it.
|
||||
{ preconnect: () => {} },
|
||||
)
|
||||
|
||||
return {
|
||||
fetch,
|
||||
inflight: () => inflight.size,
|
||||
queued: () => waiting.length,
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,6 @@ import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createConnectionSync } from "./server-sync/connection"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
|
||||
import { sameDirectory } from "@/workspaces/paths"
|
||||
|
||||
type GlobalStore = {
|
||||
path: Path
|
||||
@@ -81,17 +79,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const worktrees = createWorktreeInventory({
|
||||
scope: serverSDK.scope,
|
||||
queryClient,
|
||||
api: () => serverSDK.api.worktree,
|
||||
updated: (directory, items) =>
|
||||
setGlobalStore("project", (projects) =>
|
||||
projects.map((project) =>
|
||||
sameDirectory(project.worktree, directory) ? withWorktreeInventory(project, items) : project,
|
||||
),
|
||||
),
|
||||
})
|
||||
const bootstrap = useQuery(() => ({
|
||||
queryKey: [serverSDK.scope, "bootstrap"],
|
||||
queryFn: async () => {
|
||||
@@ -210,27 +197,17 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
|
||||
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
|
||||
setGlobalStore("project", (projects) =>
|
||||
projects.map((project) =>
|
||||
project.id === update.id
|
||||
? // The wire payload carries no worktrees; keep the inventory this project already loaded.
|
||||
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.canonical))
|
||||
: project,
|
||||
),
|
||||
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
|
||||
)
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((event) => {
|
||||
connection.handleEvent({ type: event.type })
|
||||
if (event.type === "project.updated") applyProjectUpdate(event.data)
|
||||
if (event.type === "worktree.updated") {
|
||||
const root = globalStore.project.find((project) => project.id === event.data.projectID)?.worktree
|
||||
if (root) void worktrees.refresh(root)
|
||||
void bootstrap.refetch()
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.location) {
|
||||
if (event.type === "config.updated" || event.type === "agent.updated") bootstrap.refetch()
|
||||
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
|
||||
bootstrap.refetch()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -239,6 +216,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
if (!children.children[key]) return
|
||||
children.mark(key)
|
||||
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
|
||||
if (event.type === "worktree.updated") void bootstrap.refetch()
|
||||
})
|
||||
|
||||
onCleanup(unsub)
|
||||
@@ -283,7 +261,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
// bootstrap,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
project: projectApi,
|
||||
worktrees,
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
|
||||
@@ -3,9 +3,11 @@ import { useLocation } from "@solidjs/router"
|
||||
import { ComposerEditor } from "@/composer/editor/editor"
|
||||
import { setCursorPosition } from "@/composer/editor/dom"
|
||||
import { createComposerEditor } from "@/composer/editor/interaction"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { PendingSession } from "@/shell/tabs/tabs"
|
||||
|
||||
export function PreparingComposer(props: { pending: PendingSession }) {
|
||||
const language = useLanguage()
|
||||
const location = useLocation()
|
||||
let element: HTMLElement | undefined
|
||||
const editor = createComposerEditor({
|
||||
@@ -18,6 +20,7 @@ export function PreparingComposer(props: { pending: PendingSession }) {
|
||||
},
|
||||
view: {
|
||||
draftOnly: true,
|
||||
placeholder: () => language.t("session.new.worktree.draftPlaceholder"),
|
||||
submit: { stopping: () => false, onSubmit() {}, onStop() {} },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -64,7 +64,7 @@ function PreparingSession(props: { sessionID: string; pending: PendingSession })
|
||||
provider: { all: providers.all(), default: providers.default(), connected: [] },
|
||||
}}
|
||||
>
|
||||
<div data-component="session-preparing" data-workspace-session class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div data-component="session-preparing" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<SessionIdentityHeader sessionID={props.sessionID} />
|
||||
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pb-5 md:px-5">
|
||||
<SessionUserMessage
|
||||
|
||||
@@ -58,7 +58,6 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
|
||||
type="button"
|
||||
variant="ghost-faint"
|
||||
size="small"
|
||||
icon="outline-arrow-to-corner-top-right"
|
||||
class="max-w-full"
|
||||
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
|
||||
onClick={() => props.onMove?.()}
|
||||
|
||||
@@ -40,7 +40,8 @@ export function SessionWorkspaceMenu(props: {
|
||||
if (!open) return
|
||||
const sdk = serverSDK
|
||||
void sdk.api.worktree
|
||||
.list({ location: { directory: props.directory } })
|
||||
.refresh({ projectID: props.project.id })
|
||||
.then(() => sdk.api.worktree.list({ projectID: props.project.id }))
|
||||
.then((items) =>
|
||||
setDirectories(
|
||||
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
|
||||
|
||||
@@ -58,7 +58,6 @@ export const SettingsProviders: Component<{
|
||||
(provider) =>
|
||||
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost.input > 0),
|
||||
)
|
||||
.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go"))
|
||||
})
|
||||
|
||||
const popular = createMemo(() => {
|
||||
|
||||
@@ -65,7 +65,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
Promise.all(
|
||||
(await serverSDK.api.project.list()).map(async (project) => {
|
||||
const worktrees = await serverSDK.api.worktree
|
||||
.list({ location: { directory: project.canonical } })
|
||||
.list({ projectID: project.id })
|
||||
.catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))])
|
||||
return normalizeProjectInfo({ ...project, worktrees })
|
||||
}),
|
||||
@@ -180,7 +180,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
}
|
||||
const removed = await context.sdk.api.worktree
|
||||
.remove({
|
||||
location: { directory: workspace.project.worktree },
|
||||
projectID: workspace.project.id,
|
||||
directory: workspace.directory,
|
||||
force,
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function Layout(props: ParentProps) {
|
||||
class="-end-2"
|
||||
direction="horizontal"
|
||||
size={state.tabsWidth}
|
||||
min={140}
|
||||
min={130}
|
||||
max={520}
|
||||
onResize={(width) => setState("tabsWidth", width)}
|
||||
/>
|
||||
|
||||
@@ -356,10 +356,10 @@ export function Titlebar(props: {
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
>
|
||||
<Icon name="grid-plus" class="shrink-0" />
|
||||
<Icon name="grid-plus" />
|
||||
<span class="min-w-0 truncate">{language.t("home.title")}</span>
|
||||
<span
|
||||
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
|
||||
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{command.keybind("home.toggle")}</bdi>
|
||||
@@ -654,10 +654,10 @@ export function Titlebar(props: {
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
<Icon name="edit" class="shrink-0" />
|
||||
<Icon name="edit" />
|
||||
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
|
||||
<span
|
||||
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
|
||||
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{command.keybind("tab.new")}</bdi>
|
||||
|
||||
@@ -7,27 +7,30 @@ import { createWorktree } from "./create"
|
||||
describe("worktree creation", () => {
|
||||
test.each(
|
||||
[
|
||||
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo" },
|
||||
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo", parent: "/copies/" },
|
||||
{
|
||||
name: "clone subdirectory",
|
||||
directory: "/copies/repo/packages/app",
|
||||
root: "/copies/repo",
|
||||
canonical: "/copies/repo",
|
||||
parent: "/copies/",
|
||||
},
|
||||
{
|
||||
name: "linked worktree subdirectory",
|
||||
directory: "/linked/task/packages/app",
|
||||
root: "/linked/task",
|
||||
canonical: "/copies/repo",
|
||||
parent: "/copies/",
|
||||
},
|
||||
{
|
||||
name: "Windows clone",
|
||||
directory: "C:\\copies\\repo\\packages\\app",
|
||||
root: "C:\\copies\\repo",
|
||||
canonical: "C:\\copies\\repo",
|
||||
parent: "C:/copies/",
|
||||
},
|
||||
].flatMap((input) => [true, false].map((cached) => ({ ...input, cached }))),
|
||||
)("uses the server destination and clone-local main for $name (cached: $cached)", async (input) => {
|
||||
)("uses the clone-local main for $name (cached: $cached)", async (input) => {
|
||||
const project = { id: "proj_clone", directory: input.root, canonical: input.canonical }
|
||||
const requests: Request[] = []
|
||||
const api = OpenCode.make({
|
||||
@@ -63,9 +66,10 @@ describe("worktree creation", () => {
|
||||
strategy: "git",
|
||||
from: input.canonical,
|
||||
branch: "clone-only",
|
||||
directory: input.parent,
|
||||
})
|
||||
expect(requests.find((request) => request.method === "POST")?.url).toBe(
|
||||
`http://localhost:3000/api/worktree?location%5Bdirectory%5D=${encodeURIComponent(input.directory)}`,
|
||||
"http://localhost:3000/api/worktree/proj_clone",
|
||||
)
|
||||
expect(
|
||||
requests
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { getDirectory } from "@opencode-ai/util/path"
|
||||
|
||||
export async function createWorktree(input: {
|
||||
api: Pick<OpenCodeClient, "location" | "worktree">
|
||||
@@ -10,10 +11,11 @@ export async function createWorktree(input: {
|
||||
}) {
|
||||
const project = input.project ?? (await input.api.location.get({ location: { directory: input.directory } })).project
|
||||
const created = await input.api.worktree.create({
|
||||
location: { directory: input.directory },
|
||||
projectID: project.id,
|
||||
strategy: "git",
|
||||
from: project.canonical,
|
||||
branch: input.branch,
|
||||
directory: getDirectory(project.canonical),
|
||||
})
|
||||
// Populate the client cache before the destination session mounts.
|
||||
await input.data.location.syncInfo({ directory: created.directory })
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { WorktreeDirectory } from "@opencode-ai/client/promise"
|
||||
import { createWorktreeInventory, withWorktreeInventory, worktreeInventoryKey } from "./inventory"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { normalizeProjectInfo, updateProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
|
||||
function setup(list: (directory: string) => Promise<WorktreeDirectory[]>) {
|
||||
const client = new QueryClient()
|
||||
const calls: string[] = []
|
||||
const updates: Array<[string, WorktreeDirectory[]]> = []
|
||||
const inventory = createWorktreeInventory({
|
||||
scope: ServerScope.local,
|
||||
queryClient: client,
|
||||
api: () => ({
|
||||
list: (input) => {
|
||||
const directory = input!.location!.directory!
|
||||
calls.push(directory)
|
||||
return list(directory)
|
||||
},
|
||||
}),
|
||||
updated: (directory, items) => updates.push([directory, items]),
|
||||
})
|
||||
return { client, calls, updates, inventory }
|
||||
}
|
||||
|
||||
describe("createWorktreeInventory", () => {
|
||||
test("loads once per project, shares in-flight work, and publishes the result", async () => {
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const setupResult = setup(async (directory) => {
|
||||
await gate.promise
|
||||
return [{ directory }, { directory: `${directory}/feature`, strategy: "git" }]
|
||||
})
|
||||
const first = setupResult.inventory.load("/repo")
|
||||
const second = setupResult.inventory.load("/repo/")
|
||||
expect(setupResult.calls).toEqual(["/repo"])
|
||||
gate.resolve()
|
||||
expect(await first).toHaveLength(2)
|
||||
expect(await second).toHaveLength(2)
|
||||
await setupResult.inventory.load("/repo")
|
||||
expect(setupResult.calls).toEqual(["/repo"])
|
||||
expect(setupResult.updates).toEqual([
|
||||
["/repo", [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]],
|
||||
])
|
||||
expect(setupResult.inventory.cached("/repo/")).toHaveLength(2)
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("refreshes only inventories a view already loaded", async () => {
|
||||
const setupResult = setup(async (directory) => [{ directory }])
|
||||
await setupResult.inventory.refresh("/never-opened")
|
||||
expect(setupResult.calls).toEqual([])
|
||||
await setupResult.inventory.load("/opened")
|
||||
await setupResult.inventory.refresh("/opened")
|
||||
expect(setupResult.calls).toEqual(["/opened", "/opened"])
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("a failed load is not cached and never rejects the caller", async () => {
|
||||
let fail = true
|
||||
const setupResult = setup(async (directory) => {
|
||||
if (fail) throw new Error("Location unavailable")
|
||||
return [{ directory }]
|
||||
})
|
||||
expect(await setupResult.inventory.load("/repo")).toBeUndefined()
|
||||
expect(setupResult.inventory.cached("/repo")).toBeUndefined()
|
||||
fail = false
|
||||
expect(await setupResult.inventory.load("/repo")).toEqual([{ directory: "/repo" }])
|
||||
expect(setupResult.calls).toEqual(["/repo", "/repo"])
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("keys are partitioned by server and normalized by path", () => {
|
||||
const remote = "https://remote.example" as typeof ServerScope.local
|
||||
expect(worktreeInventoryKey(ServerScope.local, "C:\\Repo\\")).toEqual(
|
||||
worktreeInventoryKey(ServerScope.local, "C:/Repo"),
|
||||
)
|
||||
expect(worktreeInventoryKey(ServerScope.local, "/repo")).not.toEqual(worktreeInventoryKey(remote, "/repo"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("withWorktreeInventory", () => {
|
||||
const metadata = {
|
||||
id: "project",
|
||||
canonical: "/repo",
|
||||
name: "Before",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
|
||||
test("derives the workspace list from the inventory, excluding the project root", () => {
|
||||
const worktrees = [
|
||||
{ directory: "/repo/" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
{ directory: "/elsewhere" },
|
||||
]
|
||||
expect(withWorktreeInventory(normalizeProjectInfo(metadata), worktrees)).toMatchObject({
|
||||
worktree: "/repo",
|
||||
sandboxes: ["/repo/feature", "/elsewhere"],
|
||||
worktrees,
|
||||
})
|
||||
})
|
||||
|
||||
test("leaves metadata untouched without an inventory and survives metadata updates", () => {
|
||||
const project = normalizeProjectInfo(metadata)
|
||||
expect(withWorktreeInventory(project, undefined)).toBe(project)
|
||||
const cached = [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]
|
||||
const updated = updateProjectInfo(withWorktreeInventory(project, cached), { ...metadata, name: "After" })
|
||||
expect(withWorktreeInventory(updated, cached)).toMatchObject({ name: "After", sandboxes: ["/repo/feature"] })
|
||||
})
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { QueryClient } from "@tanstack/solid-query"
|
||||
import type { WorktreeDirectory } from "@opencode-ai/client/promise"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { pathKey } from "./path-key"
|
||||
import { sameDirectory } from "./paths"
|
||||
|
||||
export function worktreeInventoryKey(scope: ServerScope, directory: string) {
|
||||
return [scope, "worktree", pathKey(directory)] as const
|
||||
}
|
||||
|
||||
// Project metadata arrives without worktrees; a loaded inventory supplies the workspace list.
|
||||
export function withWorktreeInventory(project: Project, worktrees: readonly WorktreeDirectory[] | undefined): Project {
|
||||
if (!worktrees) return project
|
||||
return {
|
||||
...project,
|
||||
worktrees: [...worktrees],
|
||||
sandboxes: worktrees
|
||||
.map((item) => item.directory)
|
||||
.filter((directory) => !sameDirectory(project.worktree, directory)),
|
||||
}
|
||||
}
|
||||
|
||||
// Listing a project's worktrees boots its Location on the server and runs discovery, so only
|
||||
// projects the user is looking at are loaded. Historical projects stay metadata-only.
|
||||
export function createWorktreeInventory(input: {
|
||||
scope: ServerScope
|
||||
queryClient: QueryClient
|
||||
api: () => Pick<ServerApi["worktree"], "list">
|
||||
updated: (directory: string, worktrees: WorktreeDirectory[]) => void
|
||||
}) {
|
||||
const options = (directory: string) => ({
|
||||
queryKey: worktreeInventoryKey(input.scope, directory),
|
||||
queryFn: () =>
|
||||
input
|
||||
.api()
|
||||
.list({ location: { directory } })
|
||||
.then((items) => {
|
||||
input.updated(directory, items)
|
||||
return items
|
||||
}),
|
||||
// `worktree.updated` and reconnect invalidation drive refreshes; time alone does not re-list.
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
retry: false,
|
||||
})
|
||||
return {
|
||||
cached: (directory: string) =>
|
||||
input.queryClient.getQueryData<WorktreeDirectory[]>(worktreeInventoryKey(input.scope, directory)),
|
||||
load: (directory: string) => input.queryClient.fetchQuery(options(directory)).catch(() => undefined),
|
||||
// Only inventories some view already demanded are refreshed.
|
||||
refresh: (directory: string) => {
|
||||
if (!input.queryClient.getQueryState(worktreeInventoryKey(input.scope, directory))) return Promise.resolve()
|
||||
return input.queryClient.fetchQuery({ ...options(directory), staleTime: 0 }).catch(() => undefined)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
export type { LocationContext } from "@/runtime/server/client"
|
||||
|
||||
export type WorkspaceLocation = LocationContext & {
|
||||
@@ -15,7 +15,6 @@ const context = createSimpleContext({
|
||||
name: "Location",
|
||||
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
const server = useServer()
|
||||
const data = useData()
|
||||
const ref = createMemo(
|
||||
() => ({
|
||||
@@ -41,14 +40,6 @@ const context = createSimpleContext({
|
||||
retryIf: () => !stale,
|
||||
}).catch(() => undefined)
|
||||
})
|
||||
createEffect(() => {
|
||||
const id = current()?.project.id
|
||||
if (!id || serverSDK.connection.status() !== "connected") return
|
||||
// Showing a Location is the demand for its project's worktree inventory (workspace styling, picker).
|
||||
// Key it by the metadata root so the result merges into the same global project record.
|
||||
const root = server.ctx.sync.data.project.find((project) => project.id === id)?.worktree
|
||||
if (root) void server.ctx.sync.worktrees.load(root)
|
||||
})
|
||||
|
||||
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
|
||||
return createMemo<WorkspaceLocation>(() => ({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Config } from "../../config"
|
||||
import { Context, Effect, Fiber, FileSystem, Option, Queue } from "effect"
|
||||
import { Context, Effect, FileSystem, Option, Queue } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
@@ -47,7 +47,6 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
const updater = yield* Updater.Service
|
||||
const update = yield* updater.run().pipe(Effect.forkScoped)
|
||||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
@@ -84,15 +83,11 @@ export default Runtime.handler(Commands, (input) =>
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
updater: {
|
||||
remote: requestedServer !== undefined,
|
||||
subscribe: (notify, signal) =>
|
||||
monitor: (notify, signal) =>
|
||||
runPromise(
|
||||
Fiber.join(update).pipe(
|
||||
Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))),
|
||||
),
|
||||
updater.monitor((version) => Effect.sync(() => notify(version))),
|
||||
{ signal },
|
||||
),
|
||||
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }),
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
},
|
||||
packages: {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
|
||||
export type Mode = "default" | "service" | "stdio"
|
||||
@@ -164,21 +163,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
yield* Updater.Service.pipe(
|
||||
Effect.flatMap((updater) =>
|
||||
Updater.pollUpdates({
|
||||
check: updater.run().pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (!result) return Effect.void
|
||||
if (result.type === "available") return server.updateAvailable(result.version)
|
||||
return server.updated(result.version)
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return yield* options.mode === "service"
|
||||
? server.shutdown
|
||||
: options.mode === "stdio"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type Policy = "disable" | "notify" | "auto"
|
||||
export type Action = "none" | "notify" | "auto"
|
||||
export type Policy = "disable" | "notify"
|
||||
export type Action = "none" | "notify"
|
||||
|
||||
const maximumComponent = "9007199254740991"
|
||||
const versionPattern =
|
||||
@@ -10,7 +10,7 @@ export function action(current: string, latest: string, policy: Policy): Action
|
||||
const currentVersion = parseReleaseVersion(current)
|
||||
const latestVersion = parseReleaseVersion(latest)
|
||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
||||
return policy
|
||||
return "notify"
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(input: string) {
|
||||
|
||||
@@ -6,14 +6,14 @@ describe("updater", () => {
|
||||
test("reads update policy from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("maps the v1 update policy", () => {
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
|
||||
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
|
||||
})
|
||||
|
||||
test("reports every available release", () => {
|
||||
@@ -23,11 +23,6 @@ describe("updater", () => {
|
||||
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
|
||||
})
|
||||
|
||||
test("automatically installs every available release when enabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", "auto")).toBe("auto")
|
||||
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
|
||||
})
|
||||
|
||||
test("skips when updates are disabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
@@ -9,28 +9,28 @@ import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
|
||||
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
|
||||
|
||||
export interface Interface {
|
||||
readonly run: () => Effect.Effect<RunResult | undefined>
|
||||
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
|
||||
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
|
||||
readonly apply: (version: string) => Effect.Effect<void, Error>
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export const pollUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly check: Effect.Effect<unknown>
|
||||
export const monitorUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly inspect: () => Effect.Effect<string | undefined, Error>
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly initialDelay?: Duration.Input
|
||||
readonly interval?: Duration.Input
|
||||
}) {
|
||||
const interval = input.interval ?? "10 minutes"
|
||||
return yield* input.check.pipe(
|
||||
Effect.repeat(Schedule.spaced(interval)),
|
||||
Effect.delay(input.initialDelay ?? "1 minute"),
|
||||
)
|
||||
const initialDelay = input.initialDelay ?? "90 seconds"
|
||||
const check = Effect.gen(function* () {
|
||||
const version = yield* input.inspect()
|
||||
if (version !== undefined) yield* input.notify(version)
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error })))
|
||||
return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay))
|
||||
})
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
@@ -43,20 +43,20 @@ export function decodePolicy(text: string): Policy | undefined {
|
||||
if (errors.length || typeof input !== "object" || input === null) return
|
||||
if ("update" in input) {
|
||||
const value = input.update
|
||||
if (value === "disable" || value === "notify" || value === "auto") return value
|
||||
if (value === "disable" || value === "notify") return value
|
||||
if (value === "auto") return "notify"
|
||||
return
|
||||
}
|
||||
if (!("autoupdate" in input)) return
|
||||
if (input.autoupdate === false) return "disable"
|
||||
if (input.autoupdate === "notify") return "notify"
|
||||
if (input.autoupdate === true) return "auto"
|
||||
if (input.autoupdate === true) return "notify"
|
||||
}
|
||||
|
||||
const make = Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
|
||||
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const installedPackage = yield* Effect.gen(function* () {
|
||||
const executable = yield* fs.realPath(process.execPath)
|
||||
@@ -78,7 +78,7 @@ const make = Effect.gen(function* () {
|
||||
return values.findLast((value) => value !== undefined) ?? "notify"
|
||||
})
|
||||
|
||||
const exec = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
return yield* appProcess
|
||||
.run(ChildProcess.make(command[0], command.slice(1)), {
|
||||
timeout,
|
||||
@@ -113,7 +113,7 @@ const make = Effect.gen(function* () {
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
|
||||
@@ -121,12 +121,12 @@ const make = Effect.gen(function* () {
|
||||
|
||||
const release = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
try: () =>
|
||||
fetch(
|
||||
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
|
||||
{
|
||||
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
@@ -168,20 +168,17 @@ const make = Effect.gen(function* () {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
const installer = path.join(directory, "install")
|
||||
const download = yield* exec(
|
||||
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
"5 minutes",
|
||||
)
|
||||
const download = yield* run(["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], "5 minutes")
|
||||
if (download.code !== 0) return download
|
||||
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
}
|
||||
return yield* exec(commands[method], "5 minutes")
|
||||
return yield* run(commands[method], "5 minutes")
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
if (result.code === 0) return
|
||||
@@ -203,19 +200,18 @@ const make = Effect.gen(function* () {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
const version = yield* latest()
|
||||
yield* Effect.logInfo("update check", {
|
||||
current,
|
||||
current: OPENCODE_VERSION,
|
||||
latest: version,
|
||||
})
|
||||
const next = action(current, version, policy)
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
if (next === "none") {
|
||||
yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
return undefined
|
||||
}
|
||||
yield* Effect.logInfo("OpenCode update available", { current, latest: version, action: next })
|
||||
return { policy, version }
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return version
|
||||
})
|
||||
|
||||
const install = Effect.fnUntraced(function* (version: string) {
|
||||
@@ -224,10 +220,8 @@ const make = Effect.gen(function* () {
|
||||
yield* Effect.logWarning("update skipped: installation method not found")
|
||||
return false
|
||||
}
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
yield* upgrade(detected, version)
|
||||
yield* Ref.set(installedVersion, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
|
||||
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -235,36 +229,9 @@ const make = Effect.gen(function* () {
|
||||
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
})
|
||||
|
||||
const check = Effect.fn("cli.updater.check")(function* () {
|
||||
if (OPENCODE_LOCAL)
|
||||
return {
|
||||
type: "unavailable" as const,
|
||||
message: "This build runs from a source checkout. Use an installed OpenCode release to check for updates.",
|
||||
}
|
||||
const version = yield* latest()
|
||||
if (!parseReleaseVersion(version)) return yield* Effect.fail(new Error(`Invalid version: ${version}`))
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
if (action(current, version, "auto") === "none") {
|
||||
// An earlier check may have installed the update while this client is still running.
|
||||
return action(OPENCODE_VERSION, current, "auto") === "none"
|
||||
? undefined
|
||||
: { type: "installed" as const, version: current }
|
||||
}
|
||||
return { type: "available" as const, version }
|
||||
})
|
||||
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
|
||||
|
||||
const run = Effect.fn("cli.updater.run")(
|
||||
function* () {
|
||||
const result = yield* inspect()
|
||||
if (!result) return undefined
|
||||
if (result.policy === "notify") return { type: "available" as const, version: result.version }
|
||||
if (!(yield* install(result.version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
return { type: "installed" as const, version: result.version }
|
||||
},
|
||||
Effect.catch((error) => Effect.logWarning("update check failed", { error }).pipe(Effect.as(undefined))),
|
||||
)
|
||||
|
||||
return Service.of({ run, check, apply, method, latest, upgrade })
|
||||
return Service.of({ monitor, apply, method, latest, upgrade })
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
@@ -12,8 +12,7 @@ await Effect.runPromise(
|
||||
process.argv.slice(2),
|
||||
).pipe(
|
||||
Effect.provideService(Updater.Service, {
|
||||
run: () => Effect.die("Manual upgrades must not check for automatic updates"),
|
||||
check: () => Effect.die("Manual upgrades must not check for TUI updates"),
|
||||
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
|
||||
method: () =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed("2.0.0"),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("89 seconds")
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not notify when no update is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed(undefined),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
}),
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.effect("polls after 1 minute and every 10 minutes after that", () =>
|
||||
Effect.gen(function* () {
|
||||
const checks = yield* Queue.unbounded<void>()
|
||||
yield* Updater.pollUpdates({ check: Queue.offer(checks, undefined).pipe(Effect.asVoid) }).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(checks)).toBe(0)
|
||||
yield* TestClock.adjust("59 seconds")
|
||||
expect(yield* Queue.size(checks)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Queue.take(checks)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
yield* Queue.take(checks)
|
||||
}),
|
||||
)
|
||||
@@ -1931,37 +1931,33 @@ export interface ReferenceApi<E = never> {
|
||||
readonly list: ReferenceListOperation<E>
|
||||
}
|
||||
|
||||
export type WorktreeListInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type WorktreeListInput = { readonly projectID: Project.ID }
|
||||
export type WorktreeListOutput = Worktree.List
|
||||
export type WorktreeListOperation<E = never> = (input?: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
|
||||
export type WorktreeListOperation<E = never> = (input: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
|
||||
|
||||
export type WorktreeCreateInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly strategy?: Worktree.StrategyID | undefined
|
||||
readonly projectID: Project.ID
|
||||
readonly strategy: Worktree.StrategyID
|
||||
readonly from?: AbsolutePath | undefined
|
||||
readonly branch?: string | undefined
|
||||
readonly directory?: AbsolutePath | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly name?: string | undefined
|
||||
}
|
||||
export type WorktreeCreateOutput = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input?: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
|
||||
export type WorktreeCreateOperation<E = never> = (input: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
|
||||
|
||||
export type WorktreeRemoveInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly projectID: Project.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly force: boolean
|
||||
}
|
||||
export type WorktreeRemoveOutput = void
|
||||
export type WorktreeRemoveOperation<E = never> = (input: WorktreeRemoveInput) => Effect.Effect<WorktreeRemoveOutput, E>
|
||||
|
||||
export type WorktreeRefreshInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type WorktreeRefreshInput = { readonly projectID: Project.ID }
|
||||
export type WorktreeRefreshOutput = void
|
||||
export type WorktreeRefreshOperation<E = never> = (
|
||||
input?: WorktreeRefreshInput,
|
||||
input: WorktreeRefreshInput,
|
||||
) => Effect.Effect<WorktreeRefreshOutput, E>
|
||||
|
||||
export interface WorktreeApi<E = never> {
|
||||
|
||||
@@ -1451,21 +1451,21 @@ const EndpointReferenceList = (raw: RawClient["server.reference"]) => (input?: R
|
||||
|
||||
const adaptGroupReference = (raw: RawClient["server.reference"]) => ({ list: EndpointReferenceList(raw) })
|
||||
|
||||
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input?: WorktreeListInput) =>
|
||||
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input: WorktreeListInput) =>
|
||||
preserveEffect<WorktreeListOutput>()(
|
||||
raw["worktree.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input?: WorktreeCreateInput) =>
|
||||
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: WorktreeCreateInput) =>
|
||||
preserveEffect<WorktreeCreateOutput>()(
|
||||
raw["worktree.create"]({
|
||||
query: { location: input?.["location"] },
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: {
|
||||
strategy: input?.["strategy"],
|
||||
from: input?.["from"],
|
||||
branch: input?.["branch"],
|
||||
directory: input?.["directory"],
|
||||
name: input?.["name"],
|
||||
strategy: input["strategy"],
|
||||
from: input["from"],
|
||||
branch: input["branch"],
|
||||
directory: input["directory"],
|
||||
name: input["name"],
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
@@ -1473,14 +1473,14 @@ const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input?: W
|
||||
const EndpointWorktreeRemove = (raw: RawClient["server.worktree"]) => (input: WorktreeRemoveInput) =>
|
||||
preserveEffect<WorktreeRemoveOutput>()(
|
||||
raw["worktree.remove"]({
|
||||
query: { location: input["location"] },
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: { directory: input["directory"], force: input["force"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input?: WorktreeRefreshInput) =>
|
||||
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input: WorktreeRefreshInput) =>
|
||||
preserveEffect<WorktreeRefreshOutput>()(
|
||||
raw["worktree.refresh"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
|
||||
|
||||
@@ -20,7 +20,6 @@ export type WebSearchApi = Client["websearch"]
|
||||
export type SessionApi = Client["session"]
|
||||
export type SkillApi = Client["skill"]
|
||||
export type VcsApi = Client["vcs"]
|
||||
export type WorktreeApi = Client["worktree"]
|
||||
|
||||
export interface CatalogApi {
|
||||
readonly provider: ProviderApi
|
||||
|
||||
@@ -1968,30 +1968,28 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
worktree: {
|
||||
list: (input?: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/worktree`,
|
||||
query: { location: input?.["location"] },
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
create: (input?: WorktreeCreateInput, requestOptions?: RequestOptions) =>
|
||||
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeCreateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/worktree`,
|
||||
query: { location: input?.["location"] },
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
|
||||
body: {
|
||||
strategy: input?.["strategy"],
|
||||
from: input?.["from"],
|
||||
branch: input?.["branch"],
|
||||
directory: input?.["directory"],
|
||||
name: input?.["name"],
|
||||
strategy: input["strategy"],
|
||||
from: input["from"],
|
||||
branch: input["branch"],
|
||||
directory: input["directory"],
|
||||
name: input["name"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -2003,8 +2001,7 @@ export function make(options: ClientOptions) {
|
||||
request<WorktreeRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/worktree`,
|
||||
query: { location: input["location"] },
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
|
||||
body: { directory: input["directory"], force: input["force"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -2012,12 +2009,11 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
refresh: (input?: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
|
||||
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeRefreshOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/worktree/refresh`,
|
||||
query: { location: input?.["location"] },
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}/refresh`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
|
||||
@@ -430,8 +430,6 @@ export type WebSearchProvider = { id: string; name: string }
|
||||
|
||||
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
|
||||
|
||||
export type ConfigWorktree = { directory: string }
|
||||
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
@@ -1894,7 +1892,7 @@ export type ConfigEntry =
|
||||
shell?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
default_agent?: string
|
||||
update?: "disable" | "notify" | "auto"
|
||||
update?: "disable" | "notify"
|
||||
share?: "manual" | "auto" | "disabled"
|
||||
enterprise?: { url?: string }
|
||||
username?: string
|
||||
@@ -1995,7 +1993,6 @@ export type ConfigEntry =
|
||||
}
|
||||
websearch?: false | { provider: "random" | (string & {}) }
|
||||
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } }>
|
||||
worktree?: ConfigWorktree
|
||||
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
|
||||
providers?: {
|
||||
[x: string]: {
|
||||
@@ -6080,51 +6077,45 @@ export type ReferenceListOutput = {
|
||||
data: Array<ReferenceInfo>
|
||||
}
|
||||
|
||||
export type WorktreeListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
|
||||
export type WorktreeListOutput = WorktreeList
|
||||
|
||||
export type WorktreeCreateInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly strategy?: {
|
||||
readonly strategy?: string
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly strategy: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}["strategy"]
|
||||
readonly from?: {
|
||||
readonly strategy?: string
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}["from"]
|
||||
readonly branch?: {
|
||||
readonly strategy?: string
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}["branch"]
|
||||
readonly directory?: {
|
||||
readonly strategy?: string
|
||||
readonly directory: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}["directory"]
|
||||
readonly name?: {
|
||||
readonly strategy?: string
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}["name"]
|
||||
}
|
||||
@@ -6132,20 +6123,14 @@ export type WorktreeCreateInput = {
|
||||
export type WorktreeCreateOutput = WorktreeInfo
|
||||
|
||||
export type WorktreeRemoveInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
|
||||
readonly force: { readonly directory: string; readonly force: boolean }["force"]
|
||||
}
|
||||
|
||||
export type WorktreeRemoveOutput = void
|
||||
|
||||
export type WorktreeRefreshInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
|
||||
export type WorktreeRefreshOutput = void
|
||||
|
||||
|
||||
@@ -336,7 +336,7 @@ test("file.read returns binary content from the public HTTP contract", async ()
|
||||
)
|
||||
})
|
||||
|
||||
test("all worktree operations use location-based routes without a project parameter", async () => {
|
||||
test("worktree methods use the global project contract", async () => {
|
||||
const requests: Request[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
@@ -350,25 +350,27 @@ test("all worktree operations use location-based routes without a project parame
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.worktree.list()).toEqual([{ directory: "/tmp/project" }])
|
||||
expect(await client.worktree.list({ projectID: "proj_test" })).toEqual([{ directory: "/tmp/project" }])
|
||||
expect(
|
||||
await client.worktree.create({
|
||||
projectID: "proj_test",
|
||||
strategy: "git",
|
||||
directory: "/tmp/worktrees",
|
||||
name: "api",
|
||||
}),
|
||||
).toEqual({ directory: "/tmp/worktrees/api" })
|
||||
await client.worktree.remove({
|
||||
projectID: "proj_test",
|
||||
directory: "/tmp/worktrees/api",
|
||||
force: false,
|
||||
})
|
||||
await client.worktree.refresh()
|
||||
await client.worktree.refresh({ projectID: "proj_test" })
|
||||
|
||||
expect(requests.map((request) => [request.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/worktree"],
|
||||
["POST", "http://localhost:3000/api/worktree"],
|
||||
["DELETE", "http://localhost:3000/api/worktree"],
|
||||
["POST", "http://localhost:3000/api/worktree/refresh"],
|
||||
["GET", "http://localhost:3000/api/worktree/proj_test"],
|
||||
["POST", "http://localhost:3000/api/worktree/proj_test"],
|
||||
["DELETE", "http://localhost:3000/api/worktree/proj_test"],
|
||||
["POST", "http://localhost:3000/api/worktree/proj_test/refresh"],
|
||||
])
|
||||
expect(await requests[1]?.json()).toEqual({
|
||||
strategy: "git",
|
||||
@@ -378,39 +380,6 @@ test("all worktree operations use location-based routes without a project parame
|
||||
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
|
||||
})
|
||||
|
||||
test("worktree operations send the configuration location separately from their payload", async () => {
|
||||
const requests: Request[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = new Request(input, init)
|
||||
requests.push(request)
|
||||
if (request.method === "GET") return Response.json([{ directory: "/configured/task", strategy: "git" }])
|
||||
if (request.method === "DELETE" || new URL(request.url).pathname.endsWith("/refresh"))
|
||||
return new Response(null, { status: 204 })
|
||||
return Response.json({ directory: "/configured/task" })
|
||||
},
|
||||
})
|
||||
expect(await client.worktree.create({ location: { directory: "/repo/nested" }, name: "task" })).toEqual({
|
||||
directory: "/configured/task",
|
||||
})
|
||||
expect(requests[0]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
expect(await requests[0]?.json()).toEqual({ name: "task" })
|
||||
await client.worktree.remove({
|
||||
location: { directory: "/repo/nested" },
|
||||
directory: "/configured/task",
|
||||
force: true,
|
||||
})
|
||||
await client.worktree.refresh({ location: { directory: "/repo/nested" } })
|
||||
expect(requests[1]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
expect(await requests[1]?.json()).toEqual({ directory: "/configured/task", force: true })
|
||||
expect(requests[2]?.url).toBe("http://localhost:3000/api/worktree/refresh?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
expect(await client.worktree.list({ location: { directory: "/repo/nested" } })).toEqual([
|
||||
{ directory: "/configured/task", strategy: "git" },
|
||||
])
|
||||
expect(requests[3]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
})
|
||||
|
||||
test("workspace.destroy returns the transition result", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -98,7 +98,7 @@ ${body.phone ? `${body.phone}<br>` : ""}`.trim()
|
||||
return false
|
||||
}),
|
||||
AWS.sendEmail({
|
||||
to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value,
|
||||
to: "contact@anoma.ly",
|
||||
subject: `Enterprise Inquiry from ${body.name}`,
|
||||
body: emailContent,
|
||||
replyTo: body.email,
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
|
||||
SHELL ["/bin/bash", "-lc"]
|
||||
|
||||
ARG NODE_VERSION=24.4.0
|
||||
ARG BUN_VERSION=1.4.2
|
||||
ARG BUN_VERSION=1.4.1
|
||||
|
||||
ENV BUN_INSTALL=/opt/bun
|
||||
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
@@ -12,7 +12,7 @@ ${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalo
|
||||
|
||||
## Search
|
||||
|
||||
Call \`search(...)\` to discover exact paths and signatures for additional tools:
|
||||
Use \`search\` to discover exact paths and signatures for additional tools:
|
||||
|
||||
- ${searchSignature}` : ""}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export type Inventory = {
|
||||
const description = [
|
||||
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
|
||||
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
|
||||
|
||||
@@ -74,7 +74,9 @@ export function normalize(input: unknown): Result {
|
||||
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
|
||||
: undefined
|
||||
const nativeUpdate = own(input, "update")
|
||||
? decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
? input.update === "auto"
|
||||
? "notify"
|
||||
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
: undefined
|
||||
const legacyShare = own(input, "autoshare")
|
||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
||||
@@ -209,7 +211,6 @@ export function normalize(input: unknown): Result {
|
||||
media: Info.fields.media,
|
||||
tool_output: Info.fields.tool_output,
|
||||
websearch: Info.fields.websearch,
|
||||
worktree: Info.fields.worktree,
|
||||
warming: Info.fields.warming,
|
||||
}
|
||||
Object.entries(nativeAtomic).forEach(([key, schema]) => {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
export * as ConfigWorktreePlugin from "./worktree.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import { Config } from "../../config.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../../location.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
import { Worktree } from "../../worktree.js"
|
||||
import { ConfigEntryObserver } from "./entry-observer.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.worktree",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, worktrees.reload())
|
||||
yield* worktrees.transform((editor) => {
|
||||
for (const entry of loaded.entries) {
|
||||
if (entry.type !== "document" || !entry.info.worktree) continue
|
||||
const directory = entry.info.worktree.directory
|
||||
editor.configure({
|
||||
directory: AbsolutePath.make(
|
||||
directory.startsWith("~/")
|
||||
? path.join(global.home, directory.slice(2))
|
||||
: path.resolve(entry.path ? path.dirname(entry.path) : location.directory, directory),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -25,7 +25,6 @@ import { Plugin } from "./plugin.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { InstancePlugins } from "./plugin/instance.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { WorktreeRefresh } from "./worktree/refresh.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Shell } from "./shell.js"
|
||||
@@ -72,8 +71,7 @@ const nodes = [
|
||||
PluginHooks.node,
|
||||
InstancePlugins.node,
|
||||
PluginSupervisor.node,
|
||||
WorktreeRefresh.node,
|
||||
Worktree.node,
|
||||
Worktree.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
ShellSelect.node,
|
||||
|
||||
@@ -30,7 +30,6 @@ import { Tool } from "../tool.js"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import { Vcs } from "../vcs.js"
|
||||
import { WebSearch } from "../websearch.js"
|
||||
import { Worktree } from "../worktree.js"
|
||||
import { Generate } from "../generate.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { PluginHooks } from "./hooks.js"
|
||||
@@ -70,7 +69,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
const sessions = yield* Session.Service
|
||||
const persistentPty = yield* PersistentPty.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
const locationInfo = () =>
|
||||
new Location.Info({
|
||||
directory: location.directory,
|
||||
@@ -90,24 +88,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
||||
|
||||
const atWorktree = <A, E>(
|
||||
ref: Location.Ref | undefined,
|
||||
run: (service: Worktree.Interface) => Effect.Effect<A, E>,
|
||||
) => {
|
||||
if (ref?.workspaceID) return Effect.fail(new Worktree.UnsupportedLocationError({ directory: ref.directory }))
|
||||
if (!ref || isCurrentLocation(ref)) return run(worktrees)
|
||||
return Effect.gen(function* () {
|
||||
// Defer this import: Plugin's construction depends on this host. Same-location setup calls never wait on themselves.
|
||||
const { Plugin } = yield* Effect.promise(() => import("../plugin.js"))
|
||||
const plugins = yield* Plugin.Service
|
||||
const target = yield* Worktree.Service
|
||||
yield* plugins.awaitActivation
|
||||
return yield* run(target)
|
||||
}).pipe(Effect.provide(locations.get(ref)))
|
||||
}
|
||||
const decodeWorktree = Schema.decodeUnknownEffect(Worktree.Info)
|
||||
const decodeWorktrees = Schema.decodeUnknownEffect(Schema.Array(Worktree.ListEntry))
|
||||
|
||||
const listAgents = Effect.fn("PluginHost.listAgents")((ref: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
@@ -482,25 +462,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
})
|
||||
}),
|
||||
},
|
||||
worktree: {
|
||||
list: (input) => atWorktree(locationRef(input), (service) => service.list()),
|
||||
create: (input) => atWorktree(locationRef(input), (service) => service.create(input)),
|
||||
refresh: (input) => atWorktree(locationRef(input), (service) => service.refresh()).pipe(Effect.asVoid),
|
||||
remove: (input) => atWorktree(locationRef(input), (service) => service.remove(input)),
|
||||
reload: worktrees.reload,
|
||||
transform: (callback) =>
|
||||
worktrees.transform((editor) =>
|
||||
callback({
|
||||
add: (definition) =>
|
||||
editor.add({
|
||||
id: Worktree.StrategyID.make(definition.id),
|
||||
create: (input) => definition.create(input).pipe(Effect.flatMap(decodeWorktree)),
|
||||
remove: (input) => definition.remove(input),
|
||||
list: (directory) => definition.list(directory).pipe(Effect.flatMap(decodeWorktrees)),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback, options) => hooks.register("session", name, callback, options),
|
||||
create: (input) =>
|
||||
@@ -549,7 +510,6 @@ export const requirements = LayerNode.group([
|
||||
Tool.node,
|
||||
Vcs.node,
|
||||
WebSearch.node,
|
||||
Worktree.node,
|
||||
Generate.node,
|
||||
Permission.node,
|
||||
PluginHooks.node,
|
||||
|
||||
@@ -27,8 +27,6 @@ import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
|
||||
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
|
||||
import { ConfigWorktreePlugin } from "../config/plugin/worktree.js"
|
||||
import { Worktree } from "../worktree.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
import { FileMutation } from "../file-mutation.js"
|
||||
@@ -137,7 +135,6 @@ const services = [
|
||||
ToolOutput.Service,
|
||||
Watcher.Service,
|
||||
WellKnown.Service,
|
||||
Worktree.Service,
|
||||
] as const
|
||||
|
||||
export type Requirements = Context.Service.Identifier<(typeof services)[number]>
|
||||
@@ -186,7 +183,6 @@ export const requirements = LayerNode.group([
|
||||
ToolOutput.node,
|
||||
Watcher.node,
|
||||
WellKnown.node,
|
||||
Worktree.node,
|
||||
])
|
||||
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
@@ -236,7 +232,6 @@ const post = [
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
@@ -274,12 +274,10 @@ export const OpenAIPlugin = define({
|
||||
return
|
||||
}
|
||||
const apiID = draft.modelID ?? draft.id
|
||||
const match = apiID.match(/^gpt-(\d+)(?:\.(\d+))?/)
|
||||
const major = Number(match?.[1])
|
||||
const minor = Number(match?.[2] ?? 0)
|
||||
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
||||
if (
|
||||
!codexAllowed.has(apiID) &&
|
||||
(codexDisallowed.has(apiID) || !match || !(major > 5 || (major === 5 && minor > 4)))
|
||||
(codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4)
|
||||
) {
|
||||
draft.enabled = false
|
||||
return
|
||||
|
||||
@@ -2,50 +2,42 @@ export * as SystemPromptPlugin from "./system-prompt.js"
|
||||
|
||||
import { SystemPart } from "@opencode-ai/ai"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Effect } from "effect"
|
||||
import { SessionSystemPrompt } from "../session/system-prompt.js"
|
||||
|
||||
import PROMPT_GPT from "./system-prompt/gpt.txt"
|
||||
import PROMPT_ASTRA from "./system-prompt/gpt-astra.txt"
|
||||
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
|
||||
import PROMPT_GPT from "./system-prompt/gpt-extension.txt"
|
||||
import PROMPT_KIMI from "./system-prompt/kimi.txt"
|
||||
import PROMPT_META from "./system-prompt/meta.txt"
|
||||
import PROMPT_TRINITY from "./system-prompt/trinity.txt"
|
||||
|
||||
export const OpenAIPlugin = make(
|
||||
"openai",
|
||||
(model) => {
|
||||
if (!model.id.toLowerCase().includes("gpt")) return
|
||||
export const OpenAIPlugin = make("openai", (id) => (id.includes("gpt") ? PROMPT_GPT : undefined), {
|
||||
operation: "append",
|
||||
})
|
||||
|
||||
if (model.id.toLowerCase().includes("gpt-6")) return PROMPT_ASTRA
|
||||
|
||||
return PROMPT_GPT
|
||||
},
|
||||
{ operation: "replace" },
|
||||
)
|
||||
|
||||
export const KimiPlugin = make("kimi", (model) => (model.id.toLowerCase().includes("kimi") ? PROMPT_KIMI : undefined), {
|
||||
export const AnthropicPlugin = make("anthropic", (id) => (id.includes("claude") ? PROMPT_ANTHROPIC : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const KimiPlugin = make("kimi", (id) => (id.includes("kimi") ? PROMPT_KIMI : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const ArceePlugin = make("arcee", (id) => (id.includes("trinity") ? PROMPT_TRINITY : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const ArceePlugin = make(
|
||||
"arcee",
|
||||
(model) => (model.id.toLowerCase().includes("trinity") ? PROMPT_TRINITY : undefined),
|
||||
{ operation: "replace" },
|
||||
)
|
||||
export const MetaPlugin = make(
|
||||
"meta",
|
||||
(model) => {
|
||||
if (!model.id.toLowerCase().includes("muse")) return
|
||||
return PROMPT_META.replaceAll("{{MODEL_NAME}}", model.name)
|
||||
(id) => {
|
||||
if (!id.includes("muse")) return
|
||||
const name = id.includes("muse-glimmer") ? "Muse Glimmer" : "Muse Spark"
|
||||
return PROMPT_META.replaceAll("{{MODEL_NAME}}", name)
|
||||
},
|
||||
{ operation: "replace" },
|
||||
)
|
||||
|
||||
export const Plugins = [OpenAIPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
|
||||
export const Plugins = [OpenAIPlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
|
||||
|
||||
function make(
|
||||
id: string,
|
||||
getPrompt: (model: Model.Info) => string | undefined,
|
||||
getPrompt: (modelID: string) => string | undefined,
|
||||
options: { operation: "replace" | "append" },
|
||||
) {
|
||||
return define({
|
||||
@@ -59,9 +51,8 @@ function make(
|
||||
const model = (yield* ctx.catalog.model.list()).data.find(
|
||||
(model) => model.providerID === event.model.providerID && model.id === event.model.id,
|
||||
)
|
||||
const template = getPrompt(model ?? Model.Info.default(event.model.providerID, event.model.id))
|
||||
if (!template) return
|
||||
const prompt = SessionSystemPrompt.render(template, Object.keys(event.tools))
|
||||
const prompt = getPrompt(`${model?.modelID ?? event.model.id} ${model?.family ?? ""}`.toLowerCase())
|
||||
if (!prompt) return
|
||||
if (options.operation === "append") {
|
||||
event.system.splice(1, 0, SystemPart.make(prompt))
|
||||
return
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
You are OpenCode, the best coding agent on the planet.
|
||||
|
||||
You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||
|
||||
If the user asks for help or wants to give feedback inform them of the following:
|
||||
- ctrl+p to list available actions
|
||||
- To give feedback, users should report the issue at
|
||||
https://github.com/anomalyco/opencode
|
||||
|
||||
When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the webfetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/v2/docs/
|
||||
|
||||
# Tone and style
|
||||
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like the shell tool or code comments as means to communicate with the user during the session.
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
|
||||
|
||||
# Professional objectivity
|
||||
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
|
||||
|
||||
# Doing tasks
|
||||
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
|
||||
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
|
||||
|
||||
|
||||
# Tool usage policy
|
||||
- When doing file search, prefer to use the subagent tool in order to reduce context usage.
|
||||
- You should proactively use the subagent tool with specialized agents when the task at hand matches the agent's description.
|
||||
|
||||
- When webfetch returns a message about a redirect to a different host, you should immediately make a new webfetch request with the redirect URL provided in the response.
|
||||
- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls.
|
||||
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple subagent tool calls.
|
||||
- Use specialized tools instead of shell commands when possible, as this provides a better user experience. For file operations, use dedicated tools: read for reading files instead of cat/head/tail, edit for editing instead of sed/awk, and write for creating files instead of cat with heredoc or echo redirection. Reserve the shell tool exclusively for actual system commands and terminal operations that require shell execution. NEVER use shell echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
|
||||
- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the subagent tool instead of running search commands directly.
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: [Uses the subagent tool to find the files that handle client errors instead of using glob or grep directly]
|
||||
</example>
|
||||
<example>
|
||||
user: What is the codebase structure?
|
||||
assistant: [Uses the subagent tool]
|
||||
</example>
|
||||
|
||||
# Code References
|
||||
|
||||
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
|
||||
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
|
||||
</example>
|
||||
@@ -1,45 +0,0 @@
|
||||
You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.
|
||||
|
||||
# Harness
|
||||
- Responses are rendered as GitHub-flavored Markdown.
|
||||
- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.
|
||||
- Prefer parallelizing independent tool calls.
|
||||
- Do not use a skill based solely on keywords, superficial relevance, or its availability. Avoid re-reading skills already available in the conversation unless needed.
|
||||
${OPENCODE_TOOL_GUIDANCE}
|
||||
|
||||
# Communication
|
||||
|
||||
State the main point clearly and early. Keep responses clear and concise, and avoid unnecessary technical jargon. Use only as much structure as needed, and include technical detail only when it helps the conversation. Use clear file paths when referring to files.
|
||||
|
||||
When describing your work, avoid adding what you won't do, what will remain unchanged, or how you'll separate or categorize results. Do not introduce unprompted alternatives through framing such as "X, not Y" or "This isn't about X. It's about Y."
|
||||
|
||||
## Autonomy
|
||||
|
||||
Infer the user's intent and your task scope from their instructions and the prior conversation context. You should bias towards action and carry out the user's intended task until it is completed. If the intent is unclear, progress towards the goal using the available information and ask for clarification while continuing independent work when possible.
|
||||
|
||||
When the user's prompt indicates a request for action, such as "can you...", "I want to...", "help me..." and similar expressions, treat these as instructions to take action. Do not stop at acknowledging capability (e.g. "Yes…"), proposing a plan, or offering to continue. Do not settle for a partial or "helpful enough" solution to save time, effort, or tokens. Continue until the user's intended goal is fulfilled, even when it requires sustained work.
|
||||
|
||||
## Intermediate Commentary
|
||||
|
||||
As you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.
|
||||
|
||||
By default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.
|
||||
|
||||
Do not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.
|
||||
|
||||
## Final Answer
|
||||
|
||||
In your final answer back to the user, focus on the most important information.
|
||||
|
||||
# Working in codebases
|
||||
|
||||
- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.
|
||||
- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.
|
||||
- Do not introduce unsolicited warnings, disclaimers, approval flows, or safety/compliance checklists due to hypothetical risk.
|
||||
- Do not write tests for reversible, low-impact changes or that mirror the implementation. If you do choose to verify your work with tests, make sure that the tests are meaningful and necessary to verify implementation.
|
||||
- Run tests appropriate to the change and complete required checks. Once those pass, broaden or repeat testing only when new changes, failures, or unresolved concerns justify it; otherwise, continue toward completing the task.
|
||||
|
||||
|
||||
# Delegation
|
||||
|
||||
Do not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.
|
||||
+2
-22
@@ -1,30 +1,10 @@
|
||||
You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.
|
||||
|
||||
# Harness
|
||||
- Responses are rendered as GitHub-flavored Markdown.
|
||||
- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.
|
||||
- Prefer parallelizing independent tool calls.
|
||||
${OPENCODE_TOOL_GUIDANCE}
|
||||
|
||||
# Communication
|
||||
|
||||
Use clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.
|
||||
|
||||
## Intermediate Commentary
|
||||
# Response channels
|
||||
|
||||
As you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.
|
||||
|
||||
By default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.
|
||||
|
||||
Do not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.
|
||||
|
||||
## Final Answer
|
||||
|
||||
In the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.
|
||||
|
||||
# Working in codebases
|
||||
- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.
|
||||
- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.
|
||||
In the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and skip explanation the user did not ask for. Include technical detail only where it helps.
|
||||
|
||||
# Delegation
|
||||
|
||||
@@ -409,17 +409,19 @@ export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function*
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: Promotable,
|
||||
) {
|
||||
const steer = (yield* pendingSteers(db, sessionID))[0]
|
||||
const next = (delivery: Delivery) =>
|
||||
db
|
||||
.select()
|
||||
.from(SessionInboxTable)
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, delivery)))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const steer = yield* next("steer")
|
||||
if (steer) return fromRow(steer)
|
||||
if (promotable !== "input") return undefined
|
||||
const queued = yield* db
|
||||
.select()
|
||||
.from(SessionInboxTable)
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const queued = yield* next("queue")
|
||||
return queued ? fromRow(queued) : undefined
|
||||
})
|
||||
|
||||
@@ -488,10 +490,9 @@ const publish = Effect.fn("SessionInbox.publish")(function* (
|
||||
})
|
||||
|
||||
/**
|
||||
* Promotes pending input into visible messages and returns the promoted count,
|
||||
* or undefined when the runner must first handle a pending control.
|
||||
* Steered compaction takes priority over pending prompts, without crossing a move.
|
||||
* Only the "input" scope may fall through to one queued input.
|
||||
* Promotes pending input into visible messages and returns the promoted count.
|
||||
* Steers always go first; only the "input" scope may fall through to one queued
|
||||
* input, and it then collects steers that arrived during promotion.
|
||||
*/
|
||||
export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
db: DatabaseService,
|
||||
@@ -505,7 +506,6 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
const steers = yield* pendingSteers(db, sessionID)
|
||||
if (steers.length > 0 || scope === "steer") {
|
||||
const control = steers.findIndex((row) => row.type === "compaction" || row.type === "move")
|
||||
if (control === 0) return undefined
|
||||
return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
|
||||
}
|
||||
|
||||
@@ -518,7 +518,6 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!queued) return 0
|
||||
if (queued.type === "compaction" || queued.type === "move") return undefined
|
||||
const promoted = yield* publish(db, bus, sessionID, [queued])
|
||||
const arrivedSteers = yield* pendingSteers(db, sessionID)
|
||||
const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
|
||||
@@ -537,14 +536,4 @@ const pendingSteers = (db: DatabaseService, sessionID: SessionSchema.ID) =>
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => {
|
||||
// A move changes the context's Location: never pull compaction across it.
|
||||
// Within that boundary, compact before promoting even earlier steers so
|
||||
// their text stays verbatim after the checkpoint, not inside its summary.
|
||||
const control = rows.findIndex((row) => row.type === "compaction" || row.type === "move")
|
||||
if (control > 0 && rows[control].type === "compaction") rows.unshift(...rows.splice(control, 1))
|
||||
return rows
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -147,7 +147,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
|
||||
return DrainResult.Complete()
|
||||
const ready = yield* restore(
|
||||
return yield* restore(
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* prepareContext(sessionID)
|
||||
const promoted = yield* SessionInbox.promote(
|
||||
@@ -156,8 +156,6 @@ const layer = Layer.effect(
|
||||
sessionID,
|
||||
entering && !continuing ? promotable : "steer",
|
||||
)
|
||||
// A control admitted during context preparation owns this boundary.
|
||||
if (promoted === undefined) return undefined
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
|
||||
onlyIfMissing: true,
|
||||
@@ -166,7 +164,6 @@ const layer = Layer.effect(
|
||||
return { _tag: "Ready" as const, context: yield* context.load(selected) }
|
||||
}),
|
||||
)
|
||||
if (ready) return ready
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -3,15 +3,10 @@ export * as SessionSystemPrompt from "./system-prompt.js"
|
||||
import PROMPT from "./runner/prompt/system.txt"
|
||||
|
||||
export function make(tools: string[]) {
|
||||
return render(PROMPT, tools)
|
||||
}
|
||||
|
||||
export function render(prompt: string, tools: string[]) {
|
||||
const instructions: string[] = []
|
||||
if (tools.includes("shell")) {
|
||||
instructions.push(
|
||||
"- Prefer dedicated tools over shell commands; fall back to the shell when a tool cannot do what you need.",
|
||||
"- Do not chain shell commands with separators like `echo \"====\";` or `printf '---'`; the output becomes noisy in a way that makes the user's side of the conversation worse.",
|
||||
)
|
||||
}
|
||||
if (tools.includes("write")) {
|
||||
@@ -24,5 +19,5 @@ export function render(prompt: string, tools: string[]) {
|
||||
"- Use the edit tool for targeted changes to existing text files. It replaces the exact text in `oldString` with `newString`, and the values must differ. By default, `oldString` must occur exactly once. If it occurs multiple times, include more surrounding context to make it unique or set `replaceAll` to true to replace every occurrence.",
|
||||
)
|
||||
}
|
||||
return prompt.replace("${OPENCODE_TOOL_GUIDANCE}", instructions.join("\n"))
|
||||
return PROMPT.replace("${OPENCODE_TOOL_GUIDANCE}", instructions.join("\n"))
|
||||
}
|
||||
|
||||
@@ -29,11 +29,9 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
update:
|
||||
info.autoupdate === false
|
||||
? "disable"
|
||||
: info.autoupdate === "notify"
|
||||
: info.autoupdate === "notify" || info.autoupdate === true
|
||||
? "notify"
|
||||
: info.autoupdate === true
|
||||
? "auto"
|
||||
: undefined,
|
||||
: undefined,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
|
||||
+148
-152
@@ -1,13 +1,12 @@
|
||||
export * as Worktree from "./worktree.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { ProjectSchema } from "./project/schema.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
import { Bus } from "./bus.js"
|
||||
@@ -21,10 +20,8 @@ import type { EffectDrizzleSqlite } from "./database/drizzle.js"
|
||||
import { ProjectTable } from "./project/sql.js"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export { DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
export { OperationError } from "@opencode-ai/schema/worktree"
|
||||
|
||||
export const StrategyID = Worktree.StrategyID
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
@@ -35,6 +32,11 @@ export type CreateInput = typeof CreateInput.Type
|
||||
export const RemoveInput = Worktree.RemoveInput
|
||||
export type RemoveInput = typeof RemoveInput.Type
|
||||
|
||||
export const RefreshInput = Schema.Struct({
|
||||
projectID: ProjectSchema.ID,
|
||||
}).annotate({ identifier: "Worktree.RefreshInput" })
|
||||
export type RefreshInput = typeof RefreshInput.Type
|
||||
|
||||
export const RefreshResult = Schema.Struct({
|
||||
updated: Schema.Array(AbsolutePath),
|
||||
removed: Schema.Array(AbsolutePath),
|
||||
@@ -44,10 +46,16 @@ export type RefreshResult = typeof RefreshResult.Type
|
||||
export const Info = Worktree.Info
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export const ListInput = Worktree.ListInput
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
export const List = Worktree.List
|
||||
export type List = typeof List.Type
|
||||
|
||||
export const ListEntry = Worktree.ListEntry
|
||||
export const ListEntry = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
type: Schema.Literals(["root", "worktree"]),
|
||||
}).annotate({ identifier: "Worktree.ListEntry" })
|
||||
export type ListEntry = typeof ListEntry.Type
|
||||
|
||||
export class SourceDirectoryNotFoundError extends Schema.TaggedError<SourceDirectoryNotFoundError>()(
|
||||
@@ -70,9 +78,9 @@ export class StrategyUnavailableError extends Schema.TaggedError<StrategyUnavail
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export class UnsupportedLocationError extends Schema.TaggedError<UnsupportedLocationError>()(
|
||||
"Worktree.UnsupportedLocationError",
|
||||
{ directory: AbsolutePath },
|
||||
export class DuplicateStrategyError extends Schema.TaggedError<DuplicateStrategyError>()(
|
||||
"Worktree.DuplicateStrategyError",
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
@@ -81,8 +89,6 @@ export type Error =
|
||||
| DirectoryUnavailableError
|
||||
| InvalidDirectoryError
|
||||
| StrategyUnavailableError
|
||||
| UnsupportedLocationError
|
||||
| Worktree.OperationError
|
||||
| AppProcess.AppProcessError
|
||||
| Git.WorktreeError
|
||||
|
||||
@@ -92,78 +98,67 @@ export interface Strategy {
|
||||
sourceDirectory: AbsolutePath
|
||||
directory: AbsolutePath
|
||||
branch?: string
|
||||
}) => Effect.Effect<Info, unknown>
|
||||
readonly remove: (input: { directory: AbsolutePath; force: boolean }) => Effect.Effect<void, unknown>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<readonly ListEntry[], unknown>
|
||||
}) => Effect.Effect<Info, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly remove: (input: {
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
|
||||
}
|
||||
|
||||
export const Event = Worktree.Event
|
||||
|
||||
interface StoredInput {
|
||||
readonly projectID: ProjectSchema.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly strategy?: string
|
||||
readonly replace?: boolean
|
||||
}
|
||||
|
||||
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
||||
|
||||
export interface Editor {
|
||||
readonly add: (strategy: Strategy) => void
|
||||
readonly configure: (settings: { readonly directory: AbsolutePath }) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
readonly list: () => Effect.Effect<List, Error>
|
||||
readonly create: (input?: CreateInput) => Effect.Effect<Info, Error>
|
||||
export interface Interface {
|
||||
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
|
||||
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<List>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info, Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: () => Effect.Effect<RefreshResult, Error>
|
||||
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Worktree") {}
|
||||
|
||||
export const refreshAfterBoot = Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const worktrees = yield* Service
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.logInfo("worktree refresh started", { projectID: location.project.id })
|
||||
const result = yield* worktrees.refresh({ projectID: location.project.id })
|
||||
yield* Effect.logInfo("worktree refresh done", {
|
||||
projectID: location.project.id,
|
||||
updated: result.updated,
|
||||
removed: result.removed,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
|
||||
Effect.forkScoped,
|
||||
Effect.asVoid,
|
||||
)
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const database = yield* Database.Service
|
||||
const db = database.db
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const processService = yield* AppProcess.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const projectID = location.project.id
|
||||
|
||||
const local = location.workspaceID
|
||||
? Effect.fail(new UnsupportedLocationError({ directory: location.directory }))
|
||||
: Effect.void
|
||||
|
||||
const gitStrategy = yield* WorktreeGit.make
|
||||
const state = State.create({
|
||||
name: "worktree",
|
||||
initial: () => ({
|
||||
directory: AbsolutePath.make(path.join(global.data, "worktree", projectID.slice(0, 6))),
|
||||
strategies: new Map<StrategyID, Strategy>([[gitStrategy.id, gitStrategy]]),
|
||||
selected: gitStrategy.id,
|
||||
}),
|
||||
editor: (value): Editor => ({
|
||||
configure: (settings) => {
|
||||
value.directory = settings.directory
|
||||
},
|
||||
add: (strategy) => {
|
||||
value.strategies.delete(strategy.id)
|
||||
value.strategies.set(strategy.id, strategy)
|
||||
value.selected = strategy.id
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const changed = Effect.fnUntraced(function* (update: boolean) {
|
||||
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
|
||||
if (update) yield* bus.publish(Event.Updated, { projectID })
|
||||
})
|
||||
|
||||
const ops = {
|
||||
list: Effect.fnUntraced(function* () {
|
||||
list: Effect.fn("Worktree.list")(function* (projectID: ProjectSchema.ID) {
|
||||
const rows = yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
@@ -173,7 +168,7 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.orDie)
|
||||
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
|
||||
}),
|
||||
find: Effect.fnUntraced(function* (directory: AbsolutePath) {
|
||||
find: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath) {
|
||||
const row = yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
@@ -182,21 +177,24 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.orDie)
|
||||
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
|
||||
}),
|
||||
primary: Effect.fnUntraced(function* (projectID: ProjectSchema.ID) {
|
||||
return yield* db
|
||||
.select({ directory: ProjectTable.worktree })
|
||||
.from(ProjectTable)
|
||||
.where(eq(ProjectTable.id, projectID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
create: (input: StoredInput, tx?: Transaction) =>
|
||||
(tx ?? db)
|
||||
.insert(WorktreeTable)
|
||||
.values({
|
||||
project_id: projectID,
|
||||
directory: input.directory,
|
||||
strategy: input.strategy,
|
||||
})
|
||||
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
|
||||
.onConflictDoUpdate({
|
||||
target: [WorktreeTable.project_id, WorktreeTable.directory],
|
||||
set: {
|
||||
strategy: input.strategy ?? null,
|
||||
},
|
||||
// Discovery may claim an unowned row, but never replace another strategy's ownership.
|
||||
setWhere: input.replace ? undefined : input.strategy ? isNull(WorktreeTable.strategy) : sql`false`,
|
||||
set: { strategy: input.strategy ?? null },
|
||||
setWhere: input.strategy
|
||||
? or(isNull(WorktreeTable.strategy), ne(WorktreeTable.strategy, input.strategy))
|
||||
: isNotNull(WorktreeTable.strategy),
|
||||
})
|
||||
.returning({ directory: WorktreeTable.directory })
|
||||
.get()
|
||||
@@ -204,7 +202,7 @@ const layer = Layer.effect(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => row !== undefined),
|
||||
),
|
||||
remove: (directory: AbsolutePath, tx?: Transaction) =>
|
||||
remove: (projectID: ProjectSchema.ID, directory: AbsolutePath, tx?: Transaction) =>
|
||||
(tx ?? db)
|
||||
.delete(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
|
||||
@@ -216,57 +214,62 @@ const layer = Layer.effect(
|
||||
),
|
||||
}
|
||||
|
||||
const source = Effect.fnUntraced(function* (input: AbsolutePath | undefined) {
|
||||
const sourceDirectory = input ?? location.project.directory
|
||||
const registry = new Map<StrategyID, Strategy>()
|
||||
|
||||
const register = Effect.fn("Worktree.register")(function* (strategy: Strategy) {
|
||||
if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
|
||||
registry.set(strategy.id, strategy)
|
||||
})
|
||||
|
||||
// Register default strategies
|
||||
const gitStrategy = yield* WorktreeGit.make
|
||||
yield* register(gitStrategy).pipe(Effect.orDie)
|
||||
|
||||
const source = Effect.fnUntraced(function* (input: AbsolutePath | undefined, projectID: ProjectSchema.ID) {
|
||||
const sourceDirectory = input ?? (yield* ops.primary(projectID))?.directory
|
||||
if (!sourceDirectory) return yield* new SourceDirectoryNotFoundError({ projectID })
|
||||
const resolved = yield* canonical(fs, sourceDirectory)
|
||||
if ((yield* ops.find(resolved)) === undefined)
|
||||
if ((yield* ops.find(projectID, resolved)) === undefined)
|
||||
return yield* new SourceDirectoryNotFoundError({ projectID, directory: resolved })
|
||||
return resolved
|
||||
})
|
||||
|
||||
const getStrategy = Effect.fnUntraced(function* (id: StrategyID, strategies: ReadonlyMap<StrategyID, Strategy>) {
|
||||
const found = strategies.get(id)
|
||||
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
|
||||
const found = registry.get(id)
|
||||
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
|
||||
return found
|
||||
})
|
||||
|
||||
const create = Effect.fn("Worktree.create")(function* (input: CreateInput = {}) {
|
||||
yield* local
|
||||
const current = state.get()
|
||||
const selected = yield* getStrategy(input.strategy ?? current.selected, current.strategies)
|
||||
const directory = input.directory ?? current.directory
|
||||
const sourceDirectory = yield* source(input.from)
|
||||
yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie)
|
||||
const create = Effect.fn("Worktree.create")(function* (input: CreateInput) {
|
||||
const selected = yield* getStrategy(input.strategy)
|
||||
const sourceDirectory = yield* source(input.from, input.projectID)
|
||||
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
|
||||
const name = input.name ?? Slug.create()
|
||||
let suffix = 1
|
||||
let worktreeDirectory = AbsolutePath.make(path.join(directory, name))
|
||||
let worktreeDirectory = AbsolutePath.make(path.join(input.directory, name))
|
||||
while (yield* fs.existsSafe(worktreeDirectory)) {
|
||||
suffix++
|
||||
if (suffix > 10) return yield* new DestinationExistsError({ directory: worktreeDirectory })
|
||||
worktreeDirectory = AbsolutePath.make(path.join(directory, `${name}-${suffix}`))
|
||||
worktreeDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
|
||||
}
|
||||
|
||||
const created = yield* selected
|
||||
.create({
|
||||
directory: worktreeDirectory,
|
||||
sourceDirectory,
|
||||
branch: input.branch,
|
||||
})
|
||||
.pipe(Effect.mapError((error) => operationError(selected.id, "create", error)))
|
||||
const result = { directory: yield* canonical(fs, created.directory) }
|
||||
if (result.directory !== (yield* canonical(fs, worktreeDirectory)))
|
||||
return yield* new InvalidDirectoryError({ directory: result.directory })
|
||||
const result = yield* selected.create({
|
||||
directory: worktreeDirectory,
|
||||
sourceDirectory,
|
||||
branch: input.branch,
|
||||
})
|
||||
yield* changed(
|
||||
input.projectID,
|
||||
yield* ops.create({
|
||||
projectID: input.projectID,
|
||||
directory: result.directory,
|
||||
strategy: selected.id,
|
||||
replace: true,
|
||||
strategy: input.strategy,
|
||||
}),
|
||||
)
|
||||
const project = yield* db
|
||||
.select({ commands: ProjectTable.commands })
|
||||
.from(ProjectTable)
|
||||
.where(eq(ProjectTable.id, projectID))
|
||||
.where(eq(ProjectTable.id, input.projectID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const command = project?.commands?.start?.trim()
|
||||
@@ -291,71 +294,70 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
|
||||
yield* local
|
||||
const worktreeDirectory = yield* canonical(fs, input.directory)
|
||||
const stored = yield* ops.find(worktreeDirectory)
|
||||
const stored = yield* ops.find(input.projectID, worktreeDirectory)
|
||||
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
|
||||
const strategy = yield* getStrategy(StrategyID.make(stored.strategy), state.get().strategies)
|
||||
yield* strategy
|
||||
.remove({
|
||||
directory: worktreeDirectory,
|
||||
force: input.force,
|
||||
})
|
||||
.pipe(Effect.mapError((error) => operationError(strategy.id, "remove", error)))
|
||||
yield* changed(yield* ops.remove(worktreeDirectory))
|
||||
const strategy = yield* getStrategy(StrategyID.make(stored.strategy))
|
||||
yield* strategy.remove({
|
||||
directory: worktreeDirectory,
|
||||
force: input.force,
|
||||
})
|
||||
yield* changed(input.projectID, yield* ops.remove(input.projectID, worktreeDirectory))
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("Worktree.refresh")(function* () {
|
||||
yield* local
|
||||
const stored = yield* ops.list()
|
||||
const refresh = Effect.fn("Worktree.refresh")(function* (input: RefreshInput) {
|
||||
const stored = yield* ops.list(input.projectID)
|
||||
const checked = yield* Effect.forEach(
|
||||
stored,
|
||||
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const strategies = Array.from(state.get().strategies.values()).toReversed()
|
||||
const discovered = new Map<AbsolutePath, StoredInput>()
|
||||
// A location's plugin instances only discover its own checkout, not sibling clones.
|
||||
if (checked.some((item) => item.directory === location.project.directory && item.exists)) {
|
||||
for (const strategy of strategies) {
|
||||
const entries = yield* strategy.list(location.project.directory).pipe(
|
||||
Effect.mapError((error) => operationError(strategy.id, "list", error)),
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
|
||||
)
|
||||
for (const entry of entries) {
|
||||
const directory = yield* canonical(fs, entry.directory).pipe(
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.undefined),
|
||||
)
|
||||
if (!directory || discovered.has(directory)) continue
|
||||
discovered.set(directory, {
|
||||
directory,
|
||||
strategy: entry.type === "worktree" ? strategy.id : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const sourceDirectories = checked
|
||||
.filter((item) => item.strategy === undefined && item.exists)
|
||||
.map((item) => item.directory)
|
||||
const discovered = yield* Effect.forEach(
|
||||
sourceDirectories,
|
||||
(sourceDirectory) =>
|
||||
Effect.forEach(Array.from(registry.values()), (strategy) =>
|
||||
strategy.list(sourceDirectory).pipe(
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
|
||||
Effect.map((items) =>
|
||||
items.map((item) => ({
|
||||
directory: item.directory,
|
||||
strategy: item.type === "worktree" ? strategy.id : undefined,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(
|
||||
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
|
||||
)
|
||||
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
|
||||
const changes = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.all({
|
||||
updated: Effect.filter(Array.from(discovered.values()), (item) => ops.create(item, tx)).pipe(
|
||||
Effect.map((items) => items.map((item) => item.directory)),
|
||||
),
|
||||
removed: Effect.filter(removed, (directory) => ops.remove(directory, tx)),
|
||||
updated: Effect.filter(discovered, (item) =>
|
||||
ops.create(
|
||||
{
|
||||
projectID: input.projectID,
|
||||
directory: item.directory,
|
||||
strategy: item.strategy,
|
||||
},
|
||||
tx,
|
||||
),
|
||||
).pipe(Effect.map((items) => items.map((item) => item.directory))),
|
||||
removed: Effect.filter(removed, (directory) => ops.remove(input.projectID, directory, tx)),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* changed(changes.updated.length > 0 || changes.removed.length > 0)
|
||||
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
|
||||
return changes
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list: Effect.fn("Worktree.list")(function* () {
|
||||
yield* refresh()
|
||||
return yield* ops.list()
|
||||
}),
|
||||
register,
|
||||
list: ops.list,
|
||||
create,
|
||||
remove,
|
||||
refresh,
|
||||
@@ -363,20 +365,14 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node, Location.node, Global.node],
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node],
|
||||
})
|
||||
|
||||
function operationError(strategy: StrategyID, operation: string, error: unknown) {
|
||||
if (
|
||||
error instanceof Git.WorktreeError ||
|
||||
error instanceof DirectoryUnavailableError ||
|
||||
error instanceof Worktree.OperationError
|
||||
)
|
||||
return error
|
||||
return new Worktree.OperationError({
|
||||
message: `Worktree strategy ${strategy} failed to ${operation}: ${error instanceof globalThis.Error ? error.message : String(error)}`,
|
||||
})
|
||||
}
|
||||
export const refreshNode = makeLocationNode({
|
||||
name: "worktree-refresh",
|
||||
layer: Layer.effectDiscard(refreshAfterBoot),
|
||||
deps: [node, Location.node],
|
||||
})
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
export * as WorktreeRefresh from "./refresh.js"
|
||||
|
||||
import { Effect, Layer } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Location } from "../location.js"
|
||||
import { Plugin } from "../plugin.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { Worktree } from "../worktree.js"
|
||||
|
||||
const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
if (location.workspaceID) return
|
||||
yield* plugins.awaitActivation.pipe(
|
||||
Effect.andThen(worktrees.refresh()),
|
||||
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "worktree-refresh",
|
||||
layer,
|
||||
deps: [Worktree.node, Location.node, Plugin.node, PluginSupervisor.node],
|
||||
})
|
||||
@@ -131,7 +131,6 @@ describe("CodeModeInstructions.render", () => {
|
||||
expect(partial).toContain("## Available tools")
|
||||
expect(partial).toContain("- orders (1 tool, none shown)")
|
||||
expect(partial).toContain("## Search")
|
||||
expect(partial).toContain("Call `search(...)` to discover exact paths and signatures for additional tools:")
|
||||
expect(partial).toContain("The Code Mode tool catalog below is partial.")
|
||||
expect(partial).toContain(
|
||||
"The Code Mode catalog and `search` results are the complete set of tools callable inside `execute`.",
|
||||
|
||||
@@ -666,14 +666,14 @@ describe("Config", () => {
|
||||
test("migrates the v1 update policy", () => {
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes the native auto update policy", () => {
|
||||
test("normalizes the previous native auto update policy", () => {
|
||||
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
|
||||
type: "normalized",
|
||||
encoded: { update: "auto" },
|
||||
encoded: { update: "notify" },
|
||||
diagnostics: [],
|
||||
})
|
||||
})
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n- Use clear file paths when referring to files.\\n- Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Response channels\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and skip explanation the user did not ask for. Include technical detail only where it helps.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -32,7 +32,6 @@ import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Worktree } from "@opencode-ai/core/worktree"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
import { emptyMcpLayer } from "../fixture/mcp"
|
||||
@@ -95,7 +94,6 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
||||
Vcs.node,
|
||||
Watcher.node,
|
||||
WebSearch.node,
|
||||
Worktree.node,
|
||||
]),
|
||||
[
|
||||
Location.node.replace(tempLocationLayer),
|
||||
|
||||
@@ -145,14 +145,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
transform: () => Effect.die("unused vcs.transform"),
|
||||
reload: () => Effect.die("unused vcs.reload"),
|
||||
},
|
||||
worktree: overrides.worktree ?? {
|
||||
list: () => Effect.die("unused worktree.list"),
|
||||
create: () => Effect.die("unused worktree.create"),
|
||||
remove: () => Effect.die("unused worktree.remove"),
|
||||
refresh: () => Effect.die("unused worktree.refresh"),
|
||||
transform: () => Effect.die("unused worktree.transform"),
|
||||
reload: () => Effect.die("unused worktree.reload"),
|
||||
},
|
||||
websearch: overrides.websearch ?? {
|
||||
providers: () => Effect.die("unused websearch.providers"),
|
||||
query: () => Effect.die("unused websearch.query"),
|
||||
|
||||
@@ -115,15 +115,6 @@ describe("OpenAIPlugin", () => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-4.1"), () => {})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-6-astra"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5.10"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5"), () => {})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"), () => {})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-4.99"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
@@ -170,11 +161,6 @@ describe("OpenAIPlugin", () => {
|
||||
expect(gpt56.enabled).toBe(true)
|
||||
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-6-astra"))).enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.10"))).enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(false)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe(false)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.99"))).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ describe("SystemPromptPlugin", () => {
|
||||
test("uses granular IDs with a common prefix", () => {
|
||||
expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([
|
||||
"opencode.prompt.openai",
|
||||
"opencode.prompt.anthropic",
|
||||
"opencode.prompt.kimi",
|
||||
"opencode.prompt.arcee",
|
||||
"opencode.prompt.meta",
|
||||
@@ -66,23 +67,19 @@ describe("SystemPromptPlugin", () => {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
yield* catalog.transform((editor) => {
|
||||
for (const id of ["gpt-5", "gpt-4.1", "gpt-5-codex", "gpt-6-astra"])
|
||||
for (const id of ["gpt-5", "gpt-4.1", "gpt-5-codex"])
|
||||
editor.model.update(Provider.ID.make("test"), Model.ID.make(id), () => {})
|
||||
editor.model.update(Provider.ID.make("test"), Model.ID.make("meta/muse-spark-1.1"), (model) => {
|
||||
model.name = "Muse Spark"
|
||||
})
|
||||
})
|
||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
|
||||
discard: true,
|
||||
})
|
||||
const cases = [
|
||||
["gpt-5", "# Delegation"],
|
||||
["gpt-4.1", "# Delegation"],
|
||||
["gpt-5", "# Response channels"],
|
||||
["gpt-4.1", "# Response channels"],
|
||||
["o3", fallback],
|
||||
["gpt-5-codex", "# Delegation"],
|
||||
["gpt-6-astra", "Do not settle for a partial"],
|
||||
["gpt-5-codex", "# Response channels"],
|
||||
["gemini-2.5-pro", fallback],
|
||||
["claude-sonnet-4", fallback],
|
||||
["claude-sonnet-4", "# Professional objectivity"],
|
||||
["kimi-k2", "# Prompt and Tool Use"],
|
||||
["trinity", "what command should I run to list files"],
|
||||
["meta/muse-spark-1.1", "powered by Muse Spark"],
|
||||
@@ -106,7 +103,7 @@ describe("SystemPromptPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("renders the OpenAI prompt and preserves project instructions", () =>
|
||||
it.effect("appends the OpenAI extension after the baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -116,42 +113,26 @@ describe("SystemPromptPlugin", () => {
|
||||
)
|
||||
yield* SystemPromptPlugin.OpenAIPlugin.effect(pluginHost)
|
||||
const event = context("gpt-5")
|
||||
event.system.push(SystemPart.make("Project instructions"))
|
||||
event.tools.shell = { description: "Run a command", input: { type: "object" } }
|
||||
|
||||
yield* hooks.trigger("session", "context", event)
|
||||
|
||||
expect(event.system.map((part) => part.text)).toEqual([
|
||||
expect.stringContaining("# Delegation"),
|
||||
"Project instructions",
|
||||
])
|
||||
expect(event.system[0]?.text).toStartWith("You are an AI agent powered by OpenCode")
|
||||
expect(event.system[0]?.text).toContain("Prefer dedicated tools over shell commands")
|
||||
expect(event.system[0]?.text).not.toContain("${OPENCODE_TOOL_GUIDANCE}")
|
||||
expect(event.system.map((part) => part.text)).toEqual([fallback, expect.stringContaining("# Delegation")])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses catalog names in Meta prompts for Muse model IDs", () =>
|
||||
it.effect("selects the Meta prompt for Muse family model IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
const cases = [
|
||||
["meta/muse-spark-preview", "Muse Spark Preview"],
|
||||
["muse-spark-1.2", "Muse Spark 1.2"],
|
||||
["meta/muse-glimmer-30b", "Muse Glimmer 30B"],
|
||||
["muse-glimmer-30b", "Muse Glimmer"],
|
||||
] as const
|
||||
yield* catalog.transform((editor) => {
|
||||
for (const [id, name] of cases)
|
||||
editor.model.update(Provider.ID.make("test"), Model.ID.make(id), (model) => {
|
||||
model.name = name
|
||||
})
|
||||
})
|
||||
yield* SystemPromptPlugin.MetaPlugin.effect(pluginHost)
|
||||
|
||||
yield* Effect.forEach(
|
||||
cases,
|
||||
[
|
||||
["meta/muse-spark-preview", "Muse Spark"],
|
||||
["muse-spark-1.2", "Muse Spark"],
|
||||
["meta/muse-glimmer-30b", "Muse Glimmer"],
|
||||
["muse-glimmer-30b", "Muse Glimmer"],
|
||||
] as const,
|
||||
([id, name]) => {
|
||||
const event = context(id)
|
||||
return hooks.trigger("session", "context", event).pipe(
|
||||
@@ -209,19 +190,19 @@ describe("SystemPromptPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
yield* SystemPromptPlugin.KimiPlugin.effect(pluginHost)
|
||||
yield* SystemPromptPlugin.AnthropicPlugin.effect(pluginHost)
|
||||
const gemini = context("gemini-2.5-pro")
|
||||
const kimi = context("kimi-k2")
|
||||
const claude = context("claude-sonnet-4")
|
||||
|
||||
yield* hooks.trigger("session", "context", gemini)
|
||||
yield* hooks.trigger("session", "context", kimi)
|
||||
yield* hooks.trigger("session", "context", claude)
|
||||
|
||||
expect(gemini.system[0]?.text).toBe(fallback)
|
||||
expect(kimi.system[0]?.text).toContain("# Prompt and Tool Use")
|
||||
expect(claude.system[0]?.text).toContain("# Professional objectivity")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects against the catalog ID rather than the physical model ID or family", () =>
|
||||
it.effect("selects against the catalog model ID instead of its alias", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -247,9 +228,12 @@ describe("SystemPromptPlugin", () => {
|
||||
yield* hooks.trigger("session", "context", physicalCustom)
|
||||
yield* hooks.trigger("session", "context", familyOpenAI)
|
||||
|
||||
expect(physicalOpenAI.system.map((part) => part.text)).toEqual([fallback])
|
||||
expect(physicalCustom.system.map((part) => part.text)).toEqual([expect.stringContaining("# Delegation")])
|
||||
expect(familyOpenAI.system.map((part) => part.text)).toEqual([fallback])
|
||||
expect(physicalOpenAI.system.map((part) => part.text)).toEqual([
|
||||
fallback,
|
||||
expect.stringContaining("# Delegation"),
|
||||
])
|
||||
expect(physicalCustom.system.map((part) => part.text)).toEqual([fallback])
|
||||
expect(familyOpenAI.system.map((part) => part.text)).toEqual([fallback, expect.stringContaining("# Delegation")])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
|
||||
import { Plugin } from "@opencode-ai/plugin"
|
||||
import type { WorktreeDefinition } from "@opencode-ai/plugin/effect/worktree"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { it } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
|
||||
it.live("Promise worktree callbacks receive interruption through their AbortSignal", () =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<AbortSignal>()
|
||||
const state = State.create({
|
||||
initial: () => new Map<string, WorktreeDefinition>(),
|
||||
editor: (value) => ({
|
||||
add: (definition: WorktreeDefinition) => {
|
||||
value.set(definition.id, definition)
|
||||
},
|
||||
}),
|
||||
})
|
||||
const context = host()
|
||||
const plugin = PluginPromise.fromPromise(
|
||||
Plugin.define({
|
||||
id: "cancel-worktree",
|
||||
async setup(ctx) {
|
||||
await ctx.worktree.transform((editor) =>
|
||||
editor.add({
|
||||
id: "cancel",
|
||||
create: (_input, { signal }) =>
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true })
|
||||
Effect.runSync(Deferred.succeed(started, signal))
|
||||
}),
|
||||
remove: async () => {},
|
||||
list: async () => [],
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* plugin.effect(host({ worktree: { ...context.worktree, transform: state.transform, reload: state.reload } }))
|
||||
const strategy = state.get().get("cancel")
|
||||
if (!strategy) return yield* Effect.die("Strategy was not registered")
|
||||
const fiber = yield* strategy.create({ sourceDirectory: "/source", directory: "/target" }).pipe(Effect.forkScoped)
|
||||
const signal = yield* Deferred.await(started)
|
||||
expect(signal.aborted).toBe(false)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(signal.aborted).toBe(true)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
}),
|
||||
)
|
||||
@@ -1590,6 +1590,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
defaultSystem,
|
||||
expect.stringContaining("# Delegation"),
|
||||
"Initial context",
|
||||
])
|
||||
@@ -1610,6 +1611,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
defaultSystem,
|
||||
expect.stringContaining("# Delegation"),
|
||||
"Initial context",
|
||||
])
|
||||
@@ -1923,185 +1925,6 @@ describe("SessionRunnerLLM", () => {
|
||||
).toEqual(["Replacement context"])
|
||||
})
|
||||
|
||||
for (const order of ["before", "between", "after"] as const) {
|
||||
scenario(`prioritizes manual compaction admitted ${order} two steers at the safe boundary`, function* (s) {
|
||||
s.currentModel = recoveryModel
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("Active complete", "active"),
|
||||
TestLLM.text("## Objective\n- Active work checkpoint", "summary"),
|
||||
TestLLM.text("Steers complete", "steers"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
const active = yield* s.resumePaused
|
||||
const compactID = SessionMessage.ID.create()
|
||||
if (order === "before") yield* s.session.compact({ sessionID, id: compactID })
|
||||
const first = yield* s.admit("STEER_A")
|
||||
if (order === "between") yield* s.session.compact({ sessionID, id: compactID })
|
||||
const second = yield* s.admit("STEER_B")
|
||||
if (order === "after") yield* s.session.compact({ sessionID, id: compactID })
|
||||
expect((yield* s.session.compact({ sessionID })).id).toBe(compactID)
|
||||
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect(yield* s.inbox).toHaveLength(3)
|
||||
expect((yield* s.messages).some((message) => message.type === "compaction")).toBe(false)
|
||||
yield* active.finish
|
||||
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1])).not.toContain("STEER_A")
|
||||
expect(userTexts(s.requests[1])).not.toContain("STEER_B")
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
expect((yield* s.messages).filter((message) => message.id === compactID)).toMatchObject([
|
||||
{ type: "compaction", status: "completed" },
|
||||
])
|
||||
expect((yield* s.context).filter((message) => message.type === "user").map((message) => message.id)).toEqual([
|
||||
first.id,
|
||||
second.id,
|
||||
])
|
||||
// An advisory drain must not redeliver either steer or rerun compaction.
|
||||
const runner = yield* SessionRunner.Service
|
||||
yield* runner.drain({ sessionID, force: false })
|
||||
expect(s.requests).toHaveLength(3)
|
||||
})
|
||||
}
|
||||
|
||||
scenario("waits for active tools before prioritizing compaction over pending steers", function* (s) {
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-active", "echo", { text: "active" }),
|
||||
TestLLM.text("## Objective\n- Tool work checkpoint", "summary"),
|
||||
TestLLM.text("Steers complete", "steers"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
const tools = yield* s.blockTools()
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* tools.started
|
||||
yield* s.admit("STEER_A")
|
||||
yield* s.admit("STEER_B")
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect((yield* s.messages).some((message) => message.id === compact.id)).toBe(false)
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(s.requests[1].messages.some((message) => message.role === "tool")).toBe(true)
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
scenario("rechecks compaction admitted during boundary context preparation", function* (s) {
|
||||
yield* s.runPrompt("Earlier work")
|
||||
yield* s.admit("STEER_A")
|
||||
yield* s.admit("STEER_B")
|
||||
const preparing = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
s.systemLoadHook = Deferred.succeed(preparing, undefined).pipe(Effect.andThen(Deferred.await(release)))
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("## Objective\n- Earlier work checkpoint", "summary"),
|
||||
TestLLM.text("Steers complete", "steers"),
|
||||
)
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(preparing)
|
||||
yield* s.session.compact({ sessionID })
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(run)
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
for (const outcome of ["cancelled", "failed"] as const) {
|
||||
scenario(`preserves both earlier steers when prioritized compaction is ${outcome}`, function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Active complete", "active"))
|
||||
yield* s.admit("Active work")
|
||||
const active = yield* s.resumePaused
|
||||
const first = yield* s.admit("STEER_A")
|
||||
const second = yield* s.admit("STEER_B")
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
if (outcome === "cancelled") yield* s.session.cancelInbox({ sessionID, inboxID: compact.id })
|
||||
if (outcome === "failed") yield* s.llm.push([LLMEvent.providerError({ message: "summary unavailable" })])
|
||||
yield* s.llm.push(TestLLM.text("Steers complete", "steers"))
|
||||
yield* active.finish
|
||||
|
||||
expect(s.requests).toHaveLength(outcome === "cancelled" ? 2 : 3)
|
||||
if (outcome === "failed") {
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject({
|
||||
status: "failed",
|
||||
error: { type: "provider.error", message: "summary unavailable" },
|
||||
})
|
||||
}
|
||||
if (outcome === "cancelled") expect((yield* s.messages).some((message) => message.id === compact.id)).toBe(false)
|
||||
expect(userTexts(s.requests[s.requests.length - 1]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(
|
||||
(yield* s.context)
|
||||
.filter((message) => message.id === first.id || message.id === second.id)
|
||||
.map((message) => message.id),
|
||||
).toEqual([first.id, second.id])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
scenario("keeps steers durable across interrupted priority compaction and replay", function* (s) {
|
||||
yield* s.runPrompt("Earlier work")
|
||||
const first = yield* s.admit("STEER_A")
|
||||
const second = yield* s.admit("STEER_B")
|
||||
yield* s.llm.push(TestLLM.text("## Objective\n- Interrupted checkpoint", "summary"))
|
||||
const summary = yield* s.llm.gate
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
yield* summary.started
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect((yield* s.inbox).map((item) => item.id)).toEqual([first.id, second.id])
|
||||
yield* s.session.interrupt(sessionID)
|
||||
yield* s.session.wait(sessionID)
|
||||
yield* summary.release
|
||||
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject({ status: "failed" })
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect((yield* s.inbox).map((item) => item.id)).toEqual([first.id, second.id])
|
||||
|
||||
yield* s.llm.push(TestLLM.text("Recovered steers", "steers"))
|
||||
yield* s.resume
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
scenario("does not pull compaction across an earlier move", function* (s) {
|
||||
yield* s.admit("STEER_A")
|
||||
yield* s.sessionInbox.admit({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
projectID: Project.ID.global,
|
||||
},
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
yield* s.admit("STEER_B")
|
||||
yield* s.sessionInbox.admitCompaction({ id: SessionMessage.ID.create(), sessionID, delivery: "steer" })
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("First steer complete", "first"),
|
||||
TestLLM.text("## Objective\n- Source work checkpoint", "summary"),
|
||||
TestLLM.text("Second steer complete", "second"),
|
||||
)
|
||||
yield* s.resume
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[0])).toEqual(["STEER_A"])
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(userTexts(s.requests[2]).at(-1)).toBe("STEER_B")
|
||||
expect(
|
||||
(yield* recordedEventTypes(sessionID)).filter(
|
||||
(type) => type === "session.moved.1" || type === "session.compaction.started.1",
|
||||
),
|
||||
).toEqual(["session.moved.1", "session.compaction.started.1"])
|
||||
})
|
||||
|
||||
scenario("runs steers before queued compaction and later queued input", function* (s) {
|
||||
s.currentModel = recoveryModel
|
||||
yield* s.llm.push(
|
||||
|
||||
@@ -114,6 +114,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[coreWorktree.CreateInput, Worktree.CreateInput],
|
||||
[coreWorktree.RemoveInput, Worktree.RemoveInput],
|
||||
[coreWorktree.Info, Worktree.Info],
|
||||
[coreWorktree.ListInput, Worktree.ListInput],
|
||||
[coreWorktree.List, Worktree.List],
|
||||
[coreWorktree.Event, Worktree.Event],
|
||||
[corePty.Info, Pty.Info],
|
||||
|
||||
@@ -24,7 +24,7 @@ test("execute describes invariant Code Mode behavior", () => {
|
||||
[
|
||||
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
|
||||
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import { Context, Effect, Exit, Fiber, Layer, Queue, Scope, Stream } from "effect"
|
||||
import { Effect, Fiber, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -15,64 +15,14 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Worktree } from "@opencode-ai/core/worktree"
|
||||
import { WorktreeDirectory } from "@opencode-ai/core/worktree/directory"
|
||||
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
|
||||
import { WorktreeGit } from "@opencode-ai/core/worktree/git"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigWorktreePlugin } from "@opencode-ai/core/config/plugin/worktree"
|
||||
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { host } from "./plugin/host"
|
||||
import { initRepo } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
class Fixture extends Context.Service<Fixture, Effect.Success<ReturnType<typeof makeFixture>>>()("WorktreeFixture") {}
|
||||
|
||||
const infrastructure = AppNodeBuilder.build(LayerNode.group([Project.node, Database.node, Bus.node]))
|
||||
const it = testEffect(
|
||||
Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const input = yield* makeFixture()
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
return Layer.mergeAll(
|
||||
Layer.succeed(Fixture, input),
|
||||
Config.testLayer(),
|
||||
worktreeLayer(input.sourceDirectory, input.projectID, database, bus, input.root.path),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provideMerge(infrastructure)),
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Bus.node])))
|
||||
const projectIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Project.node, Worktree.node, Database.node, Bus.node])),
|
||||
)
|
||||
const projectIt = it
|
||||
|
||||
function worktreeLayer(
|
||||
directory: AbsolutePath,
|
||||
projectID: Project.ID,
|
||||
database: Database.Interface,
|
||||
bus: Bus.Interface,
|
||||
data: string,
|
||||
workspaceID?: Workspace.ID,
|
||||
) {
|
||||
return AppNodeBuilder.build(LayerNode.group([Worktree.node, Git.node, FSUtil.node, Location.node, Global.node]), [
|
||||
Database.node.replace(Layer.succeed(Database.Service, database)),
|
||||
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
|
||||
Global.node.replace(Global.layerWith({ data })),
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
directory,
|
||||
workspaceID,
|
||||
project: { id: projectID, directory, canonical: directory },
|
||||
}),
|
||||
),
|
||||
),
|
||||
]).pipe(Layer.fresh)
|
||||
}
|
||||
|
||||
function abs(input: string) {
|
||||
return AbsolutePath.make(input)
|
||||
@@ -80,11 +30,7 @@ function abs(input: string) {
|
||||
|
||||
const gitWorktree = Worktree.StrategyID.make("git")
|
||||
|
||||
const setup = Effect.fnUntraced(function* () {
|
||||
return yield* Fixture
|
||||
})
|
||||
|
||||
function makeFixture() {
|
||||
function setup() {
|
||||
return Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -161,6 +107,7 @@ describe("Worktree", () => {
|
||||
const unavailable = Worktree.StrategyID.make("acme/missing")
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: unavailable,
|
||||
from: input.sourceDirectory,
|
||||
directory: abs(`${input.root.path}-missing-strategy`),
|
||||
@@ -184,6 +131,7 @@ describe("Worktree", () => {
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: abs(`${input.root.path}-missing-source`),
|
||||
@@ -211,6 +159,7 @@ describe("Worktree", () => {
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
@@ -224,39 +173,13 @@ describe("Worktree", () => {
|
||||
)
|
||||
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
|
||||
yield* worktree.remove({ directory: created.directory, force: false })
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("defaults to the TUI worktree directory and suffixes duplicate names", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const global = yield* Global.Service
|
||||
const parent = path.join(global.data, "worktree", "worktr")
|
||||
|
||||
const created = yield* worktree.create({
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
name: "task",
|
||||
})
|
||||
const duplicate = yield* worktree.create({
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
name: "task",
|
||||
})
|
||||
|
||||
expect(created.directory).toBe(abs(path.join(parent, "task")))
|
||||
expect(duplicate.directory).toBe(abs(path.join(parent, "task-2")))
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, ".git")).exists())).toBe(true)
|
||||
yield* worktree.remove({ directory: created.directory, force: false })
|
||||
yield* worktree.remove({ directory: duplicate.directory, force: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("runs the project setup script with worktree paths", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
@@ -276,6 +199,7 @@ describe("Worktree", () => {
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
@@ -286,7 +210,7 @@ describe("Worktree", () => {
|
||||
created.directory,
|
||||
created.directory,
|
||||
])
|
||||
yield* worktree.remove({ directory: created.directory, force: true })
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -309,12 +233,9 @@ describe("Worktree", () => {
|
||||
.quiet()
|
||||
})
|
||||
const projects = yield* Project.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
const initial = yield* projects.resolve(main)
|
||||
const selected = yield* projects.resolve(clone)
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const context = yield* Layer.build(worktreeLayer(selected.directory, selected.id, database, bus, root.path))
|
||||
const worktrees = Context.get(context, Worktree.Service)
|
||||
yield* projects.update({
|
||||
projectID: initial.id,
|
||||
commands: {
|
||||
@@ -324,6 +245,7 @@ describe("Worktree", () => {
|
||||
})
|
||||
|
||||
const created = yield* worktrees.create({
|
||||
projectID: selected.id,
|
||||
strategy: gitWorktree,
|
||||
from: selected.canonical,
|
||||
directory: abs(path.join(root.path, "worktrees")),
|
||||
@@ -354,6 +276,7 @@ describe("Worktree", () => {
|
||||
})
|
||||
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
branch: "feature-base",
|
||||
directory: parent,
|
||||
@@ -377,6 +300,7 @@ describe("Worktree", () => {
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
branch: "--no-checkout",
|
||||
directory: parent,
|
||||
@@ -397,6 +321,7 @@ describe("Worktree", () => {
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: abs(path.join(temp, "does-not-exist")),
|
||||
directory: abs(`${input.root.path}-missing-directory`),
|
||||
@@ -422,6 +347,7 @@ describe("Worktree", () => {
|
||||
]).pipe(Effect.asVoid),
|
||||
)
|
||||
const source = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: sourceParent,
|
||||
@@ -434,6 +360,7 @@ describe("Worktree", () => {
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: source.directory,
|
||||
directory: targetParent,
|
||||
@@ -441,8 +368,8 @@ describe("Worktree", () => {
|
||||
})
|
||||
|
||||
expect(created.directory).toBe(abs(path.join(targetParent, "target")))
|
||||
yield* worktree.remove({ directory: created.directory, force: false })
|
||||
yield* worktree.remove({ directory: source.directory, force: false })
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: source.directory, force: false })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -454,6 +381,7 @@ describe("Worktree", () => {
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-dirty"))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: parent,
|
||||
@@ -461,7 +389,9 @@ describe("Worktree", () => {
|
||||
})
|
||||
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
|
||||
|
||||
const error = yield* worktree.remove({ directory: created.directory, force: false }).pipe(Effect.flip)
|
||||
const error = yield* worktree
|
||||
.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Git.WorktreeError)
|
||||
if (error instanceof Git.WorktreeError) {
|
||||
@@ -471,7 +401,7 @@ describe("Worktree", () => {
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git" })
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
|
||||
|
||||
yield* worktree.remove({ directory: created.directory, force: true })
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
|
||||
expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
@@ -489,7 +419,9 @@ describe("Worktree", () => {
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const error = yield* worktree.remove({ directory: unavailable, force: false }).pipe(Effect.flip)
|
||||
const error = yield* worktree
|
||||
.remove({ projectID: input.projectID, directory: unavailable, force: false })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
|
||||
@@ -508,6 +440,7 @@ describe("Worktree", () => {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2")))
|
||||
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: parent,
|
||||
@@ -522,7 +455,7 @@ describe("Worktree", () => {
|
||||
yield* Effect.promise(() => fs.stat(path.join(parent, "worktree-2")).then((item) => item.isDirectory())),
|
||||
).toBe(true)
|
||||
|
||||
yield* worktree.remove({ directory: created.directory, force: false })
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -543,6 +476,7 @@ describe("Worktree", () => {
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: parent,
|
||||
@@ -558,6 +492,7 @@ describe("Worktree", () => {
|
||||
|
||||
it.live("does not publish an event when refresh finds no directory changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const bus = yield* Bus.Service
|
||||
const event = yield* bus.subscribe(Worktree.Event.Updated).pipe(
|
||||
@@ -567,7 +502,7 @@ describe("Worktree", () => {
|
||||
Effect.flatMap((fiber) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
yield* worktree.refresh()
|
||||
yield* worktree.refresh({ projectID: input.projectID })
|
||||
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
|
||||
}),
|
||||
),
|
||||
@@ -606,7 +541,7 @@ describe("Worktree", () => {
|
||||
|
||||
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
|
||||
const existing = abs(yield* Effect.promise(() => fs.realpath(unchanged)))
|
||||
expect(yield* worktree.refresh()).toEqual({ updated: [discovered], removed: [] })
|
||||
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
@@ -619,7 +554,7 @@ describe("Worktree", () => {
|
||||
|
||||
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
|
||||
yield* Effect.promise(() => $`git worktree remove --force ${unchanged}`.cwd(input.root.path).quiet())
|
||||
expect(yield* worktree.refresh()).toEqual({
|
||||
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({
|
||||
updated: [],
|
||||
removed: [discovered, existing].toSorted(),
|
||||
})
|
||||
@@ -640,7 +575,7 @@ describe("Worktree", () => {
|
||||
yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
|
||||
|
||||
yield* worktree.refresh()
|
||||
yield* worktree.refresh({ projectID: input.projectID })
|
||||
|
||||
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
@@ -659,7 +594,7 @@ describe("Worktree", () => {
|
||||
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
|
||||
const worktree = yield* Worktree.Service
|
||||
|
||||
yield* worktree.refresh()
|
||||
yield* worktree.refresh({ projectID: input.projectID })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
}),
|
||||
@@ -667,15 +602,9 @@ describe("Worktree", () => {
|
||||
|
||||
it.live("refresh with no roots is a no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
yield* input.db
|
||||
.delete(WorktreeTable)
|
||||
.where(eq(WorktreeTable.project_id, input.projectID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const worktree = yield* Worktree.Service
|
||||
|
||||
expect(yield* worktree.refresh()).toEqual({
|
||||
expect(yield* worktree.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
|
||||
updated: [],
|
||||
removed: [],
|
||||
})
|
||||
@@ -693,251 +622,9 @@ describe("Worktree", () => {
|
||||
.pipe(Effect.orDie)
|
||||
const worktree = yield* Worktree.Service
|
||||
|
||||
expect(yield* worktree.refresh()).toEqual({ updated: [], removed: [missing] })
|
||||
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
|
||||
|
||||
expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("defaults to Git and configured directory without depending on Config", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktrees = yield* Worktree.Service
|
||||
const parent = abs(path.join(input.root.path, "configured"))
|
||||
const registration = yield* worktrees.transform((editor) => editor.configure({ directory: parent }))
|
||||
const created = yield* worktrees.create({ name: "configured" })
|
||||
expect(created.directory).toBe(abs(path.join(parent, "configured")))
|
||||
expect(yield* worktrees.list()).toContainEqual({
|
||||
directory: created.directory,
|
||||
strategy: "git",
|
||||
})
|
||||
yield* registration.dispose
|
||||
const fallback = yield* worktrees.create({ name: "default" })
|
||||
expect(fallback.directory).toBe(
|
||||
abs(path.join(input.root.path, "worktree", input.projectID.slice(0, 6), "default")),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("selects the last active registration and restores earlier strategies on disposal", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktrees = yield* Worktree.Service
|
||||
const git = yield* WorktreeGit.make
|
||||
const parent = abs(path.join(input.root.path, "strategies"))
|
||||
const first = yield* worktrees.transform((editor) =>
|
||||
editor.add({ ...git, id: Worktree.StrategyID.make("first") }),
|
||||
)
|
||||
const scope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void))
|
||||
const second = yield* worktrees
|
||||
.transform((editor) => editor.add({ ...git, id: Worktree.StrategyID.make("second") }))
|
||||
.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
yield* worktrees.transform((editor) => editor.configure({ directory: parent }))
|
||||
const created = yield* worktrees.create({ name: "second" })
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "second" })
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* second.dispose
|
||||
const earlier = yield* worktrees.create({ name: "first" })
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: earlier.directory, strategy: "first" })
|
||||
yield* first.dispose
|
||||
const fallback = yield* worktrees.create({ name: "git" })
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: fallback.directory, strategy: "git" })
|
||||
const error = yield* worktrees.remove({ directory: created.directory, force: false }).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
|
||||
yield* worktrees.refresh()
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "second" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not fall back to Git when a registered strategy fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktrees = yield* Worktree.Service
|
||||
const git = yield* WorktreeGit.make
|
||||
yield* worktrees.transform((editor) =>
|
||||
editor.add({
|
||||
...git,
|
||||
id: Worktree.StrategyID.make("broken"),
|
||||
create: () => Effect.fail(new Error("backend failed")),
|
||||
}),
|
||||
)
|
||||
const parent = abs(path.join(input.root.path, "failures"))
|
||||
const error = yield* worktrees.create({ directory: parent, name: "failure" }).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Worktree.OperationError)
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
const explicit = yield* worktrees.create({
|
||||
directory: parent,
|
||||
name: "explicit",
|
||||
strategy: gitWorktree,
|
||||
})
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: explicit.directory, strategy: "git" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects a source override belonging to another project", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktrees = yield* Worktree.Service
|
||||
const projects = yield* Project.Service
|
||||
const other = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir()))
|
||||
yield* Effect.promise(() => initRepo(other.path))
|
||||
const resolved = yield* projects.resolve(abs(other.path))
|
||||
expect(resolved.id).not.toBe(input.projectID)
|
||||
const error = yield* worktrees.create({ from: abs(other.path), name: "nope" }).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Worktree.SourceDirectoryNotFoundError)
|
||||
if (error instanceof Worktree.SourceDirectoryNotFoundError) expect(error.projectID).toBe(input.projectID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("cannot remove a worktree from another project through the current location", () =>
|
||||
Effect.gen(function* () {
|
||||
const worktrees = yield* Worktree.Service
|
||||
const projects = yield* Project.Service
|
||||
const other = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir()))
|
||||
yield* Effect.promise(() => initRepo(other.path))
|
||||
const linked = abs(path.join(other.path, "linked"))
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${linked} HEAD`.cwd(other.path).quiet())
|
||||
const resolved = yield* projects.resolve(linked)
|
||||
const error = yield* worktrees.remove({ directory: linked, force: true }).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Worktree.InvalidDirectoryError)
|
||||
expect(yield* stored(resolved.id)).toContainEqual({ directory: linked, strategy: "git" })
|
||||
expect(yield* Effect.promise(() => fs.stat(linked).then((item) => item.isDirectory()))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects workspace-qualified locations before running worktree operations", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const context = yield* Layer.build(
|
||||
worktreeLayer(
|
||||
input.sourceDirectory,
|
||||
input.projectID,
|
||||
database,
|
||||
bus,
|
||||
input.root.path,
|
||||
Workspace.ID.make("wrk_remote"),
|
||||
),
|
||||
)
|
||||
const worktrees = Context.get(context, Worktree.Service)
|
||||
const directory = abs(path.join(input.root.path, "not-created"))
|
||||
const errors = yield* Effect.all([
|
||||
worktrees.list().pipe(Effect.flip),
|
||||
worktrees.create({ directory, name: "task" }).pipe(Effect.flip),
|
||||
worktrees.remove({ directory: input.sourceDirectory, force: true }).pipe(Effect.flip),
|
||||
worktrees.refresh().pipe(Effect.flip),
|
||||
])
|
||||
for (const error of errors) expect(error).toBeInstanceOf(Worktree.UnsupportedLocationError)
|
||||
expect(yield* fs.existsSafe(directory)).toBe(false)
|
||||
expect(yield* fs.isDir(input.sourceDirectory)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("list invokes the location's strategies before returning inventory", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktrees = yield* Worktree.Service
|
||||
const git = yield* WorktreeGit.make
|
||||
const directory = abs(path.join(input.root.path, "discovered"))
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
const sources: AbsolutePath[] = []
|
||||
yield* worktrees.transform((editor) =>
|
||||
editor.add({
|
||||
...git,
|
||||
id: Worktree.StrategyID.make("discovered-copy"),
|
||||
list: (sourceDirectory) =>
|
||||
Effect.sync(() => {
|
||||
sources.push(sourceDirectory)
|
||||
return [{ directory, type: "worktree" as const }]
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(yield* worktrees.list()).toContainEqual({ directory, strategy: "discovered-copy" })
|
||||
expect(sources).toEqual([input.sourceDirectory])
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory, strategy: "discovered-copy" })
|
||||
yield* Effect.promise(() => fs.rmdir(directory))
|
||||
expect(yield* worktrees.list()).not.toContainEqual({ directory, strategy: "discovered-copy" })
|
||||
expect(sources).toEqual([input.sourceDirectory, input.sourceDirectory])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("list surfaces strategy discovery failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const worktrees = yield* Worktree.Service
|
||||
const git = yield* WorktreeGit.make
|
||||
yield* worktrees.transform((editor) =>
|
||||
editor.add({
|
||||
...git,
|
||||
id: Worktree.StrategyID.make("broken-discovery"),
|
||||
list: () => Effect.fail(new Error("Cannot enumerate worktrees")),
|
||||
}),
|
||||
)
|
||||
const error = yield* worktrees.list().pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Worktree.OperationError)
|
||||
if (error instanceof Worktree.OperationError) expect(error.message).toContain("Cannot enumerate worktrees")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("applies directory config through the adapter and restores defaults after config removal", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const config = yield* Config.Test
|
||||
const worktrees = yield* Worktree.Service
|
||||
const bus = yield* Bus.Service
|
||||
const reloaded = yield* Queue.unbounded<void>()
|
||||
const documents = [
|
||||
new Document({
|
||||
type: "document",
|
||||
path: abs(path.join(input.root.path, "opencode.json")),
|
||||
info: new Info({ worktree: { directory: "outer" } }),
|
||||
}),
|
||||
new Document({
|
||||
type: "document",
|
||||
path: abs(path.join(input.root.path, "nested/opencode.json")),
|
||||
info: new Info({ worktree: { directory: "copies" } }),
|
||||
}),
|
||||
]
|
||||
yield* config.setEntries(documents)
|
||||
const git = yield* WorktreeGit.make
|
||||
yield* worktrees.transform((editor) => editor.add({ ...git, id: Worktree.StrategyID.make("custom") }))
|
||||
yield* ConfigWorktreePlugin.Plugin.effect(
|
||||
host({ event: { subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)) } }),
|
||||
).pipe(
|
||||
Effect.provideService(Worktree.Service, {
|
||||
...worktrees,
|
||||
reload: () => worktrees.reload().pipe(Effect.tap(() => Queue.offer(reloaded, undefined))),
|
||||
}),
|
||||
)
|
||||
const first = yield* worktrees.create({ name: "one" })
|
||||
expect(first.directory).toBe(abs(path.join(input.root.path, "nested/copies/one")))
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: first.directory, strategy: "custom" })
|
||||
yield* config.setEntries(documents.slice(0, 1))
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* Queue.take(reloaded)
|
||||
const second = yield* worktrees.create({ name: "two" })
|
||||
expect(second.directory).toBe(abs(path.join(input.root.path, "outer/two")))
|
||||
yield* config.setEntries([])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* Queue.take(reloaded)
|
||||
const third = yield* worktrees.create({ name: "three" })
|
||||
expect(third.directory).toBe(abs(path.join(input.root.path, "worktree", input.projectID.slice(0, 6), "three")))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalization retains worktree directory and rejects invalid configuration", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ConfigNormalize.normalize({ worktree: { directory: "./copies" } })).toMatchObject({
|
||||
type: "normalized",
|
||||
encoded: { worktree: { directory: "./copies" } },
|
||||
diagnostics: [],
|
||||
})
|
||||
for (const worktree of [{ directory: " " }, { directory: 12 }, {}]) {
|
||||
const result = ConfigNormalize.normalize({ worktree })
|
||||
expect(result.diagnostics.length).toBeGreaterThan(0)
|
||||
if (result.type === "normalized") expect(result.encoded).not.toHaveProperty("worktree")
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -16,4 +16,3 @@ export { Rpc } from "@opencode-ai/schema/rpc"
|
||||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
export { Vcs } from "@opencode-ai/schema/vcs"
|
||||
export { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
export { Worktree } from "@opencode-ai/schema/worktree"
|
||||
|
||||
@@ -20,7 +20,6 @@ import type { StorageDomain } from "./storage.js"
|
||||
import type { ToolDomain } from "./tool.js"
|
||||
import type { VcsDomain } from "./vcs.js"
|
||||
import type { WebSearchDomain } from "./websearch.js"
|
||||
import type { WorktreeDomain } from "./worktree.js"
|
||||
|
||||
export interface Context {
|
||||
readonly app: App
|
||||
@@ -48,7 +47,6 @@ export interface Context {
|
||||
readonly tool: ToolDomain
|
||||
readonly vcs: VcsDomain
|
||||
readonly websearch: WebSearchDomain
|
||||
readonly worktree: WorktreeDomain
|
||||
}
|
||||
|
||||
export interface Plugin<R = Scope.Scope> {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { WorktreeApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Effect } from "effect"
|
||||
import type { WorktreeCreateInput, WorktreeEntry, WorktreeRemoveInput, WorktreeResult } from "../worktree.js"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface WorktreeDefinition {
|
||||
readonly id: string
|
||||
readonly create: (input: WorktreeCreateInput) => Effect.Effect<WorktreeResult, unknown>
|
||||
readonly remove: (input: WorktreeRemoveInput) => Effect.Effect<void, unknown>
|
||||
readonly list: (sourceDirectory: string) => Effect.Effect<readonly WorktreeEntry[], unknown>
|
||||
}
|
||||
|
||||
export interface WorktreeEditor {
|
||||
/** Registers an implementation and selects it as the default. Later active registrations win. */
|
||||
add(definition: WorktreeDefinition): void
|
||||
}
|
||||
|
||||
export interface WorktreeDomain extends WorktreeApi<unknown> {
|
||||
readonly transform: Transform<WorktreeEditor>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
@@ -234,7 +234,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
const SkillEndpoints = ClientApi.groups["server.skill"].endpoints
|
||||
const VcsEndpoints = ClientApi.groups["server.vcs"].endpoints
|
||||
const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints
|
||||
const WorktreeEndpoints = ClientApi.groups["server.worktree"].endpoints
|
||||
const context = yield* Effect.context<Scope.Scope>()
|
||||
const streams = yield* makeStreams()
|
||||
|
||||
@@ -539,27 +538,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
}),
|
||||
),
|
||||
},
|
||||
worktree: {
|
||||
list: adaptApiMethod(WorktreeEndpoints["worktree.list"], host.worktree.list),
|
||||
create: adaptApiMethod(WorktreeEndpoints["worktree.create"], host.worktree.create),
|
||||
remove: adaptApiMethod(WorktreeEndpoints["worktree.remove"], host.worktree.remove),
|
||||
refresh: adaptApiMethod(WorktreeEndpoints["worktree.refresh"], host.worktree.refresh),
|
||||
reload: () => run(host.worktree.reload()),
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.worktree.transform((editor) =>
|
||||
callback({
|
||||
add: (definition) =>
|
||||
editor.add({
|
||||
id: definition.id,
|
||||
create: (input) => attempt((signal) => definition.create(input, { signal })),
|
||||
remove: (input) => attempt((signal) => definition.remove(input, { signal })),
|
||||
list: (directory) => attempt((signal) => definition.list(directory, { signal })),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback, options) =>
|
||||
register(
|
||||
|
||||
@@ -17,4 +17,3 @@ export { Rpc } from "@opencode-ai/schema/rpc"
|
||||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
export { Vcs } from "@opencode-ai/schema/vcs"
|
||||
export { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
export { Worktree } from "@opencode-ai/schema/worktree"
|
||||
|
||||
@@ -20,7 +20,6 @@ import type { StorageDomain } from "./storage.js"
|
||||
import type { ToolDomain } from "./tool.js"
|
||||
import type { VcsDomain } from "./vcs.js"
|
||||
import type { WebSearchDomain } from "./websearch.js"
|
||||
import type { WorktreeDomain } from "./worktree.js"
|
||||
|
||||
export interface Context {
|
||||
readonly app: App
|
||||
@@ -48,7 +47,6 @@ export interface Context {
|
||||
readonly tool: ToolDomain
|
||||
readonly vcs: VcsDomain
|
||||
readonly websearch: WebSearchDomain
|
||||
readonly worktree: WorktreeDomain
|
||||
}
|
||||
|
||||
export type Cleanup = () => Promise<void> | void
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { WorktreeApi } from "@opencode-ai/client/promise/api"
|
||||
import type { WorktreeCreateInput, WorktreeEntry, WorktreeRemoveInput, WorktreeResult } from "../worktree.js"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface WorktreeDefinition {
|
||||
readonly id: string
|
||||
readonly create: (input: WorktreeCreateInput, context: { readonly signal: AbortSignal }) => Promise<WorktreeResult>
|
||||
readonly remove: (input: WorktreeRemoveInput, context: { readonly signal: AbortSignal }) => Promise<void>
|
||||
readonly list: (
|
||||
sourceDirectory: string,
|
||||
context: { readonly signal: AbortSignal },
|
||||
) => Promise<readonly WorktreeEntry[]>
|
||||
}
|
||||
|
||||
export interface WorktreeEditor {
|
||||
/** Registers an implementation and selects it as the default. Later active registrations win. */
|
||||
add(definition: WorktreeDefinition): void
|
||||
}
|
||||
|
||||
export interface WorktreeDomain extends WorktreeApi {
|
||||
readonly transform: Transform<WorktreeEditor>
|
||||
readonly reload: () => Promise<void>
|
||||
}
|
||||
@@ -162,21 +162,6 @@ type PromptFooterInput = {
|
||||
readonly showDetails: boolean
|
||||
}
|
||||
|
||||
export type PanelPresentation = "panel" | "fullscreen"
|
||||
|
||||
/** Client-local state of the selected session panel. The host owns its layout and input scope. */
|
||||
export interface PanelInput {
|
||||
/** Selected content name, set by ui.panel.open. Contributions decide whether to render it. */
|
||||
readonly name: string
|
||||
readonly sessionID: string
|
||||
readonly width: number
|
||||
readonly presentation: PanelPresentation
|
||||
readonly focused: boolean
|
||||
readonly focus: () => void
|
||||
readonly close: () => void
|
||||
readonly toggleFullscreen: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
|
||||
* may render around, inside, or take over. Paths are absolute and
|
||||
@@ -195,7 +180,6 @@ export interface SlotMap {
|
||||
readonly "prompt.footer.status": PromptFooterInput
|
||||
readonly "prompt.footer.file": PromptFooterInput
|
||||
readonly "session.composer.top": { readonly sessionID: string }
|
||||
readonly "session.panel": PanelInput
|
||||
readonly "sidebar.content": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": { readonly sessionID: string }
|
||||
}
|
||||
@@ -466,14 +450,6 @@ export interface UI {
|
||||
navigate(destination: Destination): void
|
||||
current(): Route
|
||||
}
|
||||
readonly panel: {
|
||||
/** Opens the session.panel slot in the current session. */
|
||||
open(name: string, options?: { readonly presentation?: PanelPresentation }): boolean
|
||||
/** Closes this plugin's active panel. Other plugins' panels are unaffected. */
|
||||
close(): void
|
||||
/** This plugin's active panel, if any. Reactive when read in a Solid computation. */
|
||||
current(): { readonly name: string; readonly sessionID: string } | undefined
|
||||
}
|
||||
readonly tabs: {
|
||||
/** Returns whether session tabs are enabled for this TUI. */
|
||||
enabled(): boolean
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
export interface WorktreeCreateInput {
|
||||
readonly sourceDirectory: string
|
||||
readonly directory: string
|
||||
/** Starting ref, not the name of a new branch. Reject unsupported refs rather than ignoring them. */
|
||||
readonly branch?: string
|
||||
}
|
||||
|
||||
export interface WorktreeRemoveInput {
|
||||
readonly directory: string
|
||||
readonly force: boolean
|
||||
}
|
||||
|
||||
export interface WorktreeResult {
|
||||
readonly directory: string
|
||||
}
|
||||
|
||||
export interface WorktreeEntry extends WorktreeResult {
|
||||
readonly type: "root" | "worktree"
|
||||
}
|
||||
+57
-219
@@ -11997,50 +11997,18 @@
|
||||
"summary": "List references"
|
||||
}
|
||||
},
|
||||
"/api/worktree": {
|
||||
"/api/worktree/{projectID}": {
|
||||
"get": {
|
||||
"tags": ["worktree"],
|
||||
"operationId": "v2.worktree.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"name": "projectID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -12056,18 +12024,11 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "WorktreeError | InvalidRequestError",
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/WorktreeErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12083,7 +12044,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Discover worktrees through the requested location's strategies and return its project's inventory.",
|
||||
"description": "List known local worktrees for a project.",
|
||||
"summary": "List worktrees"
|
||||
},
|
||||
"post": {
|
||||
@@ -12091,44 +12052,12 @@
|
||||
"operationId": "v2.worktree.create",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"name": "projectID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -12171,13 +12100,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.",
|
||||
"description": "Create a worktree for a project and run its configured setup script.",
|
||||
"summary": "Create worktree",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Worktree.CreateInput"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"strategy": {
|
||||
"type": "string"
|
||||
},
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"branch": {
|
||||
"type": "string"
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["strategy", "directory"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -12189,44 +12137,12 @@
|
||||
"operationId": "v2.worktree.remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"name": "projectID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -12262,13 +12178,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Remove a managed worktree from the requested location's project using its recorded strategy.",
|
||||
"description": "Remove a managed worktree from a project.",
|
||||
"summary": "Remove worktree",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Worktree.RemoveInput"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"force": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["directory", "force"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -12276,50 +12202,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/worktree/refresh": {
|
||||
"/api/worktree/{projectID}/refresh": {
|
||||
"post": {
|
||||
"tags": ["worktree"],
|
||||
"operationId": "v2.worktree.refresh",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"name": "projectID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -12355,7 +12249,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Discover worktrees from the requested location and reconcile the shared project inventory.",
|
||||
"description": "Reconcile stored worktrees with the project repositories.",
|
||||
"summary": "Refresh worktrees"
|
||||
}
|
||||
},
|
||||
@@ -14010,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
"enum": ["disable", "notify"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
@@ -14252,9 +14146,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
"$ref": "#/components/schemas/Config.Worktree"
|
||||
},
|
||||
"warming": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -14572,16 +14463,6 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Worktree": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["directory"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ConfigWebSearch.InfoEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18340,12 +18221,6 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -18573,9 +18448,6 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19695,27 +19567,6 @@
|
||||
"additionalProperties": false,
|
||||
"description": "Reports whether this request destroyed an existing workspace."
|
||||
},
|
||||
"Worktree.CreateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"strategy": {
|
||||
"type": "string"
|
||||
},
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"branch": {
|
||||
"type": "string"
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Worktree.Directory": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19745,19 +19596,6 @@
|
||||
"$ref": "#/components/schemas/Worktree.Directory"
|
||||
}
|
||||
},
|
||||
"Worktree.RemoveInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"force": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["directory", "force"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"WorktreeErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19891,7 +19729,7 @@
|
||||
},
|
||||
{
|
||||
"name": "worktree",
|
||||
"description": "Location-scoped worktree management routes."
|
||||
"description": "Project worktree management routes."
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user