Compare commits

..
Author SHA1 Message Date
Aiden Cline b53b3d3635 fix(ai): never fail decoding on Responses error frames 2026-09-16 14:19:23 -05:00
138 changed files with 867 additions and 2601 deletions
+2 -2
View File
@@ -54,7 +54,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
### [x] `GET /api/health` and `GET /api/server`
- **Decision:** Merge and rename
- **Replacement:** `GET /api/info` with operation ID `server.info`.
- **Replacement:** `GET /api/status` with operation ID `server.status`.
- **Notes:** Returns `version`, `pid`, and connection `urls`; readiness is conveyed by HTTP status.
### [x] `GET /api/project/current`
@@ -74,7 +74,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 001002 | `GET` | `/api/info` | `server.info` | Keep | Replaces the former health and server endpoints. |
| [x] 001002 | `GET` | `/api/status` | `server.status` | Keep | Replaces the former health and server endpoints. |
| [x] 003 | `GET` | `/api/location` | `location.get` | Keep | Workspace selectors and response fields removed until workspace support ships. |
| [x] 004 | `GET` | `/api/project` | `project.list` | Keep | Removed unused `time.initialized`; the database column remains for migration data. |
| [x] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | Keep | Request and response accepted as-is. |
@@ -73,11 +73,6 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
)
if (event.type === "error") {
terminal = true
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame, cause),
),
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(event, `${options.name} stream error`, frame),
@@ -108,7 +108,7 @@ const incremental = (
return input.slice(baseline.length)
}
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
const code = (event: OpenResponses.Event) => OpenResponses.errorDetail(event).code
const rejected = (
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
+28 -94
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Effect, Option, Schema } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -333,53 +333,13 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// Responses-compatible providers put error details at the top level, under `error`, or under
// `response.error`, and gateways reshape them freely: strings, numeric codes, extra fields. Those
// fields decode as opaque values and `errorDetail` reads them defensively, so an error frame can
// only fail on invalid JSON and otherwise always classifies with the raw body as the fallback.
// https://www.openresponses.org/specification
const OpenResponsesErrorObject = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
Schema.decodeTo(OpenResponsesErrorObject, {
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
encode: SchemaGetter.passthrough(),
}),
)
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
export const WebSocketErrorEvent = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("error"),
status: Schema.optional(Schema.Number),
status_code: Schema.optional(Schema.Number),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
export const decodeKnownErrorEvent = (event: Event) =>
decodeWebSocketErrorEvent({
...event,
status: typeof event.status === "number" ? event.status : undefined,
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
headers: ProviderShared.isRecord(event.headers)
? Object.fromEntries(
Object.entries(event.headers).filter(
(entry): entry is [string, string | number | boolean] =>
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
),
)
: undefined,
})
const asText = (value: unknown) =>
typeof value === "string" && value.length > 0 ? value : typeof value === "number" ? String(value) : undefined
export const Event = Schema.StructWithRest(
Schema.Struct({
@@ -400,31 +360,18 @@ export const Event = Schema.StructWithRest(
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
output: Schema.optional(Schema.Array(StreamItem)),
usage: optionalNull(OpenResponsesUsage),
error: optionalNull(OpenResponsesErrorPayload),
error: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
code: Schema.optional(Schema.Unknown),
message: Schema.optional(Schema.Unknown),
error: Schema.optional(Schema.Unknown),
status: Schema.optional(Schema.Unknown),
status_code: Schema.optional(Schema.Unknown),
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
).pipe(
Schema.decode({
decode: SchemaGetter.transform((event) => {
if (event.type !== "error" || event.error != null) return event
const { code, message, param, ...rest } = event
if (code === undefined && message === undefined && param === undefined) return event
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
return { ...rest, error: { code, message, param } }
}),
encode: SchemaGetter.passthrough(),
}),
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
@@ -433,16 +380,15 @@ const decodeEventValue = Schema.decodeUnknownEffect(Event)
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
/**
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
* Decodes one WebSocket frame. Some providers and gateways answer a rejected `response.create` with a bare
* `{ "error": ... }` envelope and no event type; that reads as an error event so it classifies instead of
* failing decoding.
*/
export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) &&
value.type === undefined &&
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
ProviderShared.isRecord(value) && value.type === undefined && value.error != null
? { ...value, type: "error" }
: value,
),
@@ -1422,22 +1368,21 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
return [{ ...current, lifecycle }, events] satisfies StepResult
})
// Build the prettiest summary available from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
// the bare message — production rate limits and context-length failures used
// to be indistinguishable from generic stream drops. Returns undefined when
// the payload carries no usable summary.
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
const message = event.message || nested?.message || undefined
const code = event.code || nested?.code || undefined
if (message && code) return `${code}: ${message}`
return message || code
/** Error code and message from wherever the frame put them; top-level fields win over nested ones. */
export const errorDetail = (event: Event) => {
const raw = event.error ?? event.response?.error
const nested = typeof raw === "string" ? { message: raw } : ProviderShared.isRecord(raw) ? raw : undefined
return {
message: asText(event.message) ?? asText(nested?.message),
code: asText(event.code) ?? asText(nested?.code),
}
}
// Prefix the code when both are present (`rate_limit_exceeded: Slow down`) so the failure mode is
// visible; fall back to the raw frame rather than a generic message when neither decodes.
export const providerFailure = (event: Event, fallback: string, body = ProviderShared.encodeJson(event)) => {
const nested = event.error ?? event.response?.error ?? undefined
const summary = providerErrorMessage(event, nested)
const detail = errorDetail(event)
const summary = detail.message && detail.code ? `${detail.code}: ${detail.message}` : (detail.message ?? detail.code)
const message = summary ?? (body === "{}" ? fallback : body)
const status =
typeof event.status === "number"
@@ -1520,18 +1465,7 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "error")
return decodeKnownErrorEvent(event).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(
state.id,
`${state.name} returned a malformed error event`,
ProviderShared.encodeJson(event),
cause,
),
),
Effect.flatMap(() => providerFailure(event, `${state.name} stream error`)),
)
if (event.type === "error") return providerFailure(event, `${state.name} stream error`)
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
+6 -21
View File
@@ -59,15 +59,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
const QUOTA_CODES = new Set([
"insufficient_quota",
"usage_not_included",
"billing_error",
"gousagelimiterror",
"freeusagelimiterror",
"creditlimitexceeded",
])
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
const SERVER_CODES = new Set([
"api_error",
@@ -95,8 +87,7 @@ const CONTENT_POLICY_CODES = new Set([
// as a `[code]` label at the start of the rewritten message.
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
// Only consulted on 429, where throttles and account caps share a status.
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
// Policy rejections without a dedicated code, matched against the provider's own
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
@@ -152,11 +143,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
return new ContentPolicyError(details)
if (
input.status === 402 ||
codes.some((code) => QUOTA_CODES.has(code)) ||
(input.status === 429 && QUOTA_TEXT.test(text))
)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededError(details)
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
return new AuthenticationError(details)
@@ -176,12 +163,10 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
input.status === 408 ||
input.status === 409 ||
(input.status !== undefined && input.status >= 500) ||
// Server codes and phrasing only decide when no HTTP status contradicts them:
// gateways such as OpenCode Zen substitute `server_error` for codes they do
// not forward, so a 4xx with a server code is still a rejected request.
((input.status === undefined || input.status < 400) &&
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
)
return new ProviderInternalError({
...details,
+3 -3
View File
@@ -309,7 +309,7 @@ describe("RequestExecutor", () => {
}),
)
it.effect("does not let server codes override a 4xx rejection", () =>
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
Effect.gen(function* () {
const classify = (body: string) =>
Effect.gen(function* () {
@@ -317,11 +317,11 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
yield* classify('{"code":"service_unavailable"}')
}),
)
+3 -47
View File
@@ -249,54 +249,10 @@ describe("provider error classification", () => {
test("classifies any remaining 4xx status as an invalid request", () => {
expect(
[400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(Array(5).fill("InvalidRequest"))
})
test("classifies 402 as exhausted quota", () => {
expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded")
})
test("classifies OpenCode Zen account limits as quota rather than throttling", () => {
const typed = (type: string, message: string) => ({ type: "error", error: { type, message } })
const substituted = (message: string) => ({
error: { type: "server_error", message: `Upstream request failed: ${message}` },
})
const cases: ReadonlyArray<[number, { error: { message: string } }]> = [
[429, typed("GoUsageLimitError", "Go usage limit exceeded")],
[429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")],
[402, typed("CreditLimitExceeded", "Credit limit exceeded.")],
[402, substituted("Insufficient account funds")],
[402, substituted("Account invoice is overdue")],
[429, substituted("Account budget exceeded")],
]
expect(
cases.map(
([status, body]) =>
classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag,
[400, 402, 404, 418, 422, 451].map(
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
),
).toEqual(Array(6).fill("QuotaExceeded"))
})
test("does not let substituted server codes make a 4xx retryable", () => {
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
const anthropic = {
type: "error",
error: { type: "api_error", message: "Upstream request failed: Model is unavailable." },
}
expect(
[openai, anthropic].map(
(body) =>
classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(["InvalidRequest", "InvalidRequest"])
// Without a contradicting status the same codes still mark provider trouble.
expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe(
"ProviderInternal",
)
expect(
classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag,
).toBe("ProviderInternal")
).toEqual(Array(6).fill("InvalidRequest"))
})
test("classifies nested provider codes when a top-level code is also present", () => {
@@ -11,66 +11,78 @@ import { sseEvents } from "../lib/sse.js"
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
it.effect("decodes error frames verbatim in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const frame = {
type: "error",
sequence_number: 4,
code: "server_shutting_down",
message: "Server is shutting down. Please retry your request.",
param: null,
}
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
const event = yield* decode(JSON.stringify(frame))
expect(event).toEqual({
type: "error",
sequence_number: 4,
error: { code: frame.code, message: frame.message, param: null },
})
for (const unchanged of [
event,
for (const frame of [
{ type: "error", sequence_number: 4, code: "server_shutting_down", message: "Shutting down", param: null },
{ type: "error" },
{ type: "error", error: "Gateway failed" },
{ type: "error", error: { code: 429, message: "slow down" } },
{ type: "error", error: 42 },
{ type: "error", code: 500, message: ["not", "a", "string"] },
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
{
type: "response.failed",
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
},
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
]) {
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
expect(yield* decode(JSON.stringify(frame))).toEqual(frame)
}
}
}),
)
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
it.effect("reads bare WebSocket error envelopes as error events", () =>
Effect.gen(function* () {
const message = "gRPC error: Response with id=resp_missing not found"
for (const error of [{ type: "api_error", message }, message]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
type: "error",
error: typeof error === "string" ? { message } : error,
})
for (const error of [{ type: "api_error", message }, message, 42]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({ type: "error", error })
}
for (const frame of [{ error: null }, { message }]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame)).pipe(Effect.flip)).toBeDefined()
}
}),
)
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
it.effect("extracts error details from every shape and falls back to the raw frame", () =>
Effect.gen(function* () {
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
type: "error",
error: { message: "Gateway failed" },
})
expect(
yield* decode(
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
),
).toEqual({
type: "response.failed",
response: { id: "resp_failed", error: { message: "Gateway failed" } },
})
const cases: Array<[frame: Record<string, unknown>, message: string, tag: string]> = [
[
{ type: "error", code: "server_shutting_down", message: "Shutting down" },
"server_shutting_down: Shutting down",
"UnknownProvider",
],
[{ type: "error", error: "Gateway failed" }, "Gateway failed", "UnknownProvider"],
[{ type: "error", error: { code: 429, message: "slow down" } }, "429: slow down", "UnknownProvider"],
[{ type: "error", error: { message: "slow down" }, status: 429 }, "slow down", "RateLimit"],
[{ type: "error", code: 500, message: ["not", "a", "string"] }, "500", "UnknownProvider"],
[
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
"Gateway failed",
"UnknownProvider",
],
]
for (const [frame, message, tag] of cases) {
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
expect(error.message).toBe(message)
expect(error.reason._tag).toBe(tag)
expect(error.reason.body).toBe(JSON.stringify(frame))
}
for (const frame of [
{ type: "error", error: 42 },
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
]) {
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
expect(error.message).toBe(JSON.stringify(frame))
expect(error.reason._tag).toBe("UnknownProvider")
}
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").message).toBe("fallback")
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").reason._tag).toBe("ProviderInternal")
}),
)
@@ -62,8 +62,7 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/info")
return json(route, { version: "test", pid: 1, urls: [url.origin], paths: { tmp: "/tmp/opencode" } })
if (url.pathname === "/api/status") return json(route, { version: "test", pid: 1, urls: [url.origin] })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
@@ -16,13 +16,8 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
})
}
if (url.pathname === "/api/info") {
return json(route, {
version: "2.0.0",
pid: 1,
urls: [url.origin],
paths: { tmp: "/tmp/opencode" },
})
if (url.pathname === "/api/status") {
return json(route, { version: "2.0.0", pid: 1, urls: [url.origin] })
}
return json(route, {})
})
@@ -138,14 +138,7 @@ test("MCP authentication starts before a slow resource catalog finishes", async
test("multiple desktop connections show the session's server name", async ({ page }) => {
await mockStressTimeline(page)
await page.route("http://secondary.test/**", (route) =>
route.fulfill({
json: {
version: "2.0.0",
pid: 1,
urls: ["http://secondary.test"],
paths: { tmp: "/tmp/opencode" },
},
}),
route.fulfill({ json: { version: "2.0.0", pid: 1, urls: ["http://secondary.test"] } }),
)
await page.addInitScript(
({ directory, server }) => {
@@ -109,11 +109,11 @@ test("passes through non-event fetches", async ({ page }) => {
const timeline = await setupTimeline(page)
const health = await page.evaluate(async () => {
const response = await fetch("/api/info")
const response = await fetch("/api/status")
return response.json()
})
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [], paths: { tmp: "/tmp/opencode" } })
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [] })
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
})
@@ -172,6 +172,64 @@ test("Models and Shortcuts autofocus their filters on normal navigation", async
await expect(result).toBeFocused()
})
for (const count of [7, 8]) {
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(count)
await view.search.clear()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
await expect(projects).toHaveCount(count)
if (count === 7) {
await expect(search).toHaveCount(0)
return
}
await expect(search).toBeFocused()
await search.fill(" CODE 06 ")
await expect(projects).toHaveCount(1)
await expect(projects).toHaveAccessibleName("OpenCode 06")
await expect(search).toBeVisible()
await search.fill("missing-project")
await expect(projects).toHaveCount(0)
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toBeFocused()
await expect(projects).toHaveCount(count)
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(search).toBeFocused()
await search.fill("OpenCode 06")
await projects.click()
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
})
}
test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
await page.route("**/api/project", async (route) => {
await inventory.promise
await route.fulfill({ json: projectList(8) })
})
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
await page.reload()
await requested
const view = ui(page)
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
try {
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
await expect(search).toHaveCount(0)
} finally {
inventory.resolve()
}
await expect(search).toBeFocused()
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
})
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
const view = ui(page)
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
@@ -81,10 +81,10 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
response.setHeader("cache-control", "no-store")
if (path === "/observer.html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Worker observer</title>")
if (path === "/api/info")
if (path === "/api/status")
return void response
.writeHead(200, { "content-type": "application/json" })
.end(`{"version":"test","pid":1,"urls":["${url.origin}"],"paths":{"tmp":"/tmp/opencode"}}`)
.end(`{"version":"test","pid":1,"urls":["${url.origin}"]}`)
if (path === "/sw.js" && state.legacy && state.version === "old") {
// Model the shipped worker's shared precache name and cache-first navigation behavior.
const urls = Object.keys(builds.old).filter(
@@ -334,13 +334,8 @@ fixture("upgrades the legacy shared precache only after old tabs close", async (
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
await install(page, site.url)
const api = await page.goto(`${site.url}/api/info`)
expect(await api?.json()).toEqual({
version: "test",
pid: 1,
urls: ["http://localhost"],
paths: { tmp: "/tmp/opencode" },
})
const api = await page.goto(`${site.url}/api/status`)
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: ["http://localhost"] })
expect(api?.fromServiceWorker()).toBe(false)
const asset = await page.goto(`${site.url}/_assets/missing.js`)
expect(asset?.status()).toBe(404)
+1 -1
View File
@@ -33,7 +33,7 @@ export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBa
}) {}
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("info", "/api/info", { success: Json }))
.add(HttpApiEndpoint.get("status", "/api/status", { success: Json }))
.add(
HttpApiEndpoint.get("event", "/api/event", {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
+1 -7
View File
@@ -219,13 +219,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
}),
)
.handleAll({
info: () =>
Effect.succeed({
version: "2.0.0",
pid: 1,
urls: config.server ? [config.server] : [],
paths: { tmp: "/tmp/opencode" },
}),
status: () => Effect.succeed({ version: "2.0.0", pid: 1, urls: config.server ? [config.server] : [] }),
config: () => Effect.succeed(configEntries),
reference: () =>
Effect.succeed({
@@ -1,19 +0,0 @@
import { expect, test } from "bun:test"
import { shouldHandlePasteAsAttachment } from "./interaction"
test("leaves paste to the browser when web clipboard data is unavailable", () => {
expect(shouldHandlePasteAsAttachment(clipboard(), false)).toBe(false)
})
test("uses native image reading only when the clipboard has no text", () => {
expect(shouldHandlePasteAsAttachment(clipboard(), true)).toBe(true)
expect(shouldHandlePasteAsAttachment(clipboard(["text/plain"]), true)).toBe(false)
})
test("handles clipboard files as attachments", () => {
expect(shouldHandlePasteAsAttachment(clipboard([], [{ kind: "file" }]), false)).toBe(true)
})
function clipboard(types: string[] = [], items: Array<{ kind: string }> = []) {
return { types, items } as unknown as DataTransfer
}
@@ -384,20 +384,20 @@ export function createComposerEditor(input: {
},
onPaste(event: ClipboardEvent) {
const clipboard = event.clipboardData
const text = clipboard?.getData("text/plain")
if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) {
if (
attachments &&
(Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain"))
) {
void attachments.handlePaste(event)
return
}
const text = clipboard?.getData("text/plain").replace(/\r\n?/g, "\n")
if (!text) return
event.preventDefault()
// insertText emits input events per line, repeatedly parsing and saving the draft.
// Escaped HTML inserts multiline text once and preserves native selection and undo.
const normalized = text.replace(/\r\n?/g, "\n")
const multiline = normalized.includes("\n")
const value = multiline
? normalized.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
: normalized
const multiline = text.includes("\n")
const value = multiline ? text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;") : text
if (
typeof document.execCommand === "function" &&
document.execCommand(multiline ? "insertHTML" : "insertText", false, value)
@@ -408,13 +408,13 @@ export function createComposerEditor(input: {
if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return
const range = selection.getRangeAt(0)
range.deleteContents()
const node = document.createTextNode(normalized)
const node = document.createTextNode(text)
range.insertNode(node)
range.setStartAfter(node)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: normalized }))
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text }))
},
onDragEnter(event: DragEvent) {
event.preventDefault()
@@ -450,12 +450,6 @@ export function createComposerEditor(input: {
export type ComposerEditorModel = ReturnType<typeof createComposerEditor>
export function shouldHandlePasteAsAttachment(clipboard: DataTransfer | null, readClipboardImage: boolean) {
if (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file")) return true
if (Array.from(clipboard?.types ?? []).some((type) => type.startsWith("text/"))) return false
return readClipboardImage
}
function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
const position = Math.max(0, Math.min(cursor, text.length))
if (inHistory) return position === 0 || position === text.length
-1
View File
@@ -15,7 +15,6 @@ export type {
BrowserPaneTarget,
} from "./runtime/platform/browser-pane"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useGlobal } from "./runtime/server/runtime"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
+5 -10
View File
@@ -90,12 +90,7 @@ test("rotates HTTP and PTY clients together", async () => {
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ url: request.url, authorization: request.headers.get("authorization") })
return Response.json({
version: "2.0.0-test",
pid: 1,
urls: [request.url],
paths: { tmp: "/tmp/opencode" },
})
return Response.json({ version: "2.0.0-test", pid: 1, urls: [request.url] })
}) as typeof globalThis.fetch
const transport = createServerTransport({
http: { url: "http://127.0.0.1:4100", password: "first" },
@@ -103,23 +98,23 @@ test("rotates HTTP and PTY clients together", async () => {
})
const initialPty = transport.pty
await transport.api.server.info()
await transport.api.server.status()
const replacement = transport.update({
url: "http://127.0.0.1:4200",
password: "second",
})
await transport.api.server.info()
await transport.api.server.status()
expect(replacement).toBe(transport.api)
expect(transport.pty).not.toBe(initialPty)
expect(transport.url).toBe("http://127.0.0.1:4200")
expect(requests).toEqual([
{
url: "http://127.0.0.1:4100/api/info",
url: "http://127.0.0.1:4100/api/status",
authorization: `Basic ${btoa("opencode:first")}`,
},
{
url: "http://127.0.0.1:4200/api/info",
url: "http://127.0.0.1:4200/api/status",
authorization: `Basic ${btoa("opencode:second")}`,
},
])
@@ -5,7 +5,6 @@ import { createStore } from "solid-js/store"
import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import { createServerTransport } from "@/runtime/server/client"
import type { ServerSync } from "@/runtime/server/sync"
import { worktreeInventoryKey } from "@/workspaces/inventory"
@@ -65,44 +64,6 @@ test("bootstraps projects through the native store setter and preserves subseque
}
})
// Chromium aborts in-flight loopback requests with ERR_NETWORK_CHANGED when Windows reconfigures an
// adapter; the client wraps that as ClientError("Transport"), which the bootstrap retry must see through.
test("recovers project metadata after the connection to the server is dropped", async () => {
const body = JSON.stringify([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
let dropped = 0
const requests: string[] = []
const server = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
open(socket) {
if (dropped >= 2) return
dropped += 1
socket.terminate()
},
data(socket, chunk) {
requests.push(String(chunk).split(" ")[0] ?? "")
socket.end(
`HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: ${Buffer.byteLength(body)}\r\naccess-control-allow-origin: *\r\nconnection: close\r\n\r\n${body}`,
)
},
},
})
const transport = createServerTransport({ http: { url: `http://127.0.0.1:${server.port}` } })
try {
const result = await new QueryClient({ defaultOptions: { queries: { retry: false } } }).fetchQuery(
loadProjectsQuery(ServerScope.local, transport.api.project),
)
expect(dropped).toBe(2)
// happy-dom's fetch adds a CORS preflight; only the GET is the retried API call.
expect(requests.filter((method) => method === "GET")).toHaveLength(1)
expect(result).toMatchObject([{ id: "project", worktree: "/repo" }])
} finally {
server.stop(true)
}
})
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const location = {} as ServerApi["location"]
+15 -29
View File
@@ -17,12 +17,7 @@ describe("checkServerHealth", () => {
const headers: Array<string | null> = []
const fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
headers.push(new Headers(init?.headers).get("authorization"))
return Response.json({
version: "2.0.0",
pid: 1,
urls: [server.url],
paths: { tmp: "/tmp/opencode" },
})
return Response.json({ version: "2.0.0", pid: 1, urls: [server.url] })
}) as typeof globalThis.fetch
expect(await checkServerHealth({ ...server, password }, fetch)).toEqual({ healthy: true, version: "2.0.0" })
@@ -33,19 +28,16 @@ describe("checkServerHealth", () => {
let request: URL | undefined
const fetch = (async (input: RequestInfo | URL) => {
request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" })
expect(request?.pathname).toBe("/api/info")
expect(request?.pathname).toBe("/api/status")
})
test("allows slow servers thirty seconds by default", async () => {
@@ -60,7 +52,7 @@ describe("checkServerHealth", () => {
})
const fetch = (async () =>
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }), {
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch
@@ -119,13 +111,10 @@ describe("checkServerHealth", () => {
let signal: AbortSignal | undefined
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
signal = abortFromInput(input, init)
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
}) as unknown as typeof globalThis.fetch
const abort = new AbortController()
@@ -141,13 +130,10 @@ describe("checkServerHealth", () => {
const fetch = (async () => {
count += 1
if (count < 3) throw new TypeError("network")
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch, {
+1 -1
View File
@@ -95,7 +95,7 @@ export async function checkServerHealth(
fetch,
headers,
})
.server.info({ signal })
.server.status({ signal })
.then((status) => ({ data: { healthy: true as const, version: status.version } }))
.catch((error) => ({ error }))
if ("data" in current) return current.data
+9 -4
View File
@@ -8,8 +8,11 @@ import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistenc
import type { SshItem } from "@/servers/ssh/types"
type ServerState = ReturnType<typeof serverState>["current"]["Type"]
// Retain closed paths until reopened so settings can exclude them from the server inventory.
// The Home page independently limits the visible recently closed entries.
// The store retains more history than is displayed. Consumers filter recently closed entries
// against the live project list (dropping deleted projects) and then cap the visible count via
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
// filtered out do not evict still-visible ones from the persisted store.
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
export function normalizeServerUrl(input: string) {
@@ -48,7 +51,6 @@ export function createServerProjects(input: {
}
return {
list: current,
closed: currentClosed,
recentlyClosed: currentClosed,
remove,
open(directory: string) {
@@ -70,7 +72,10 @@ export function createServerProjects(input: {
close(directory: string) {
remove(directory)
const key = pathKey(directory)
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)]
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
0,
RECENTLY_CLOSED_HISTORY_LIMIT,
)
setStore("recentlyClosed", input.scope(), closed)
},
expand(directory: string) {
@@ -195,7 +195,7 @@ describe("createRequestQueue", () => {
input.tick(50)
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
input.tick(100)
input.queue.fetch("http://server/api/info").catch(() => undefined)
input.queue.fetch("http://server/api/status").catch(() => undefined)
expect(input.logs).toEqual([])
input.tick(2_000)
await new Promise((resolve) => setTimeout(resolve, 20))
@@ -210,7 +210,7 @@ describe("createRequestQueue", () => {
],
queued: [
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
{ method: "GET", url: "http://server/api/info", ms: 2_000 },
{ method: "GET", url: "http://server/api/status", ms: 2_000 },
],
},
},
+1 -6
View File
@@ -146,11 +146,7 @@ function createServerController(
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
// icon from the database instead of using their individual overrides.
const base = {
...metadata,
...(!metadata || metadata.id === "global" ? childStore.projectMeta : undefined),
...project,
}
const base = { ...metadata, ...project }
if (childStore.icon) {
return { ...base, icon: { ...base.icon, override: childStore.icon } }
}
@@ -178,7 +174,6 @@ function createServerController(
projects: {
...projects,
list: projectsList,
resolve: enrich,
recentlyClosed: recentlyClosedList,
},
notification,
+6 -14
View File
@@ -6,19 +6,12 @@ import { useLanguage } from "@/runtime/i18n/language"
export function SettingsSearchEmpty(props: { query: string }) {
const language = useLanguage()
const [state, setState] = createStore({ query: props.query })
let container: HTMLDivElement | undefined
let quoted: HTMLSpanElement | undefined
let measure: HTMLSpanElement | undefined
const text = (query: string) => language.t("settings.search.empty.query", { query })
const update = () => {
if (!container || !measure) return
const style = getComputedStyle(container)
measure.textContent = language.t("settings.search.empty", { query: "" })
// Measure the available query space independently of its current truncated text.
const width =
container.clientWidth -
parseFloat(style.paddingInlineStart) -
parseFloat(style.paddingInlineEnd) -
measure.getBoundingClientRect().width
if (!quoted || !measure) return
const width = quoted.getBoundingClientRect().width
const fits = (query: string) => {
measure!.textContent = text(query)
return measure!.getBoundingClientRect().width <= width
@@ -47,22 +40,21 @@ export function SettingsSearchEmpty(props: { query: string }) {
createEffect(update)
onMount(() => {
if (!container || !measure) return
if (!quoted || !measure) return
// The measuring text also observes font changes that do not resize the available space.
createResizeObserver([container, measure], update)
createResizeObserver([quoted, measure], update)
})
return (
<>
<div
ref={container}
class="settings-search-empty"
role="status"
aria-label={language.t("settings.search.empty", { query: text(props.query) })}
>
{language.rich("settings.search.empty", {
query: (
<span class="settings-search-empty-quoted">
<span ref={quoted} class="settings-search-empty-quoted">
<bdi dir="auto">{text(state.query)}</bdi>
</span>
),
+6 -8
View File
@@ -15,12 +15,11 @@
visibility: hidden;
}
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
.settings-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
color: var(--v2-icon-icon-faint);
cursor: default;
}
:is(.settings-search, .settings-projects-search)
.settings-search
[data-slot="text-input-v2-icon-button"][data-variant="clear"]:is(:hover, :active, :focus-visible):not(:disabled) {
background: transparent;
color: var(--v2-icon-icon-base);
@@ -38,20 +37,19 @@
background: var(--v2-overlay-simple-overlay-hover);
}
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-start="true"] {
.settings-search [data-slot="text-input-v2-input"][data-overflow-start="true"] {
--mask-start: 16px;
}
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-end="true"] {
.settings-search [data-slot="text-input-v2-input"][data-overflow-end="true"] {
--mask-end: 16px;
}
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"]:dir(rtl) {
.settings-search [data-slot="text-input-v2-input"]:dir(rtl) {
--mask-direction: to left;
}
:is(.settings-search, .settings-projects-search)
[data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
.settings-search [data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
mask-image: linear-gradient(
var(--mask-direction, to right),
transparent,
@@ -7,18 +7,14 @@ import { sshName, type SshItem } from "@/servers/ssh/types"
import type { ServerCtx } from "@/runtime/server/runtime"
import { pathKey } from "@/workspaces/path-key"
export function settingsProjects(context: {
projects: Pick<ServerCtx["projects"], "list" | "closed" | "resolve">
sync: { data: Pick<ServerCtx["sync"]["data"], "project"> }
}) {
export function settingsProjects(context: ServerCtx) {
const tracked = context.projects.list()
const paths = new Set(tracked.map((project) => pathKey(project.worktree)))
const closed = new Set(context.projects.closed().map(pathKey))
return [
...tracked,
...context.sync.data.project
.filter((project) => !paths.has(pathKey(project.worktree)) && !closed.has(pathKey(project.worktree)))
.map((project) => context.projects.resolve({ worktree: project.worktree, expanded: false })),
.filter((project) => !paths.has(pathKey(project.worktree)))
.map((project) => ({ ...project, expanded: false })),
]
}
-72
View File
@@ -803,78 +803,6 @@
padding-inline-end: 28px;
}
.settings-projects-search [data-component="text-input-v2"] {
height: 36px;
background: color-mix(in oklab, var(--v2-background-bg-layer-02) 60%, transparent);
box-shadow: none;
}
.settings-projects-search [data-component="text-input-v2"]:is(:hover, :focus-within):not([data-disabled]) {
background: var(--v2-background-bg-layer-02);
}
.settings-projects-search [data-slot="text-input-v2-input"] {
padding-inline-end: 0;
}
.settings-projects-search [data-slot="text-input-v2-leading-icon"] {
padding-inline-start: 16px;
}
.settings-projects-search [data-slot="text-input-v2-value"] {
gap: 12px;
}
.settings-projects-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
margin-inline-end: -4px;
}
.settings-projects-empty {
width: 60%;
margin-inline: auto;
}
.settings-projects-empty .settings-search-empty {
justify-content: center;
padding-inline: 0;
}
.settings-projects-empty .settings-search-empty-quoted {
flex: 0 1 auto;
}
.settings-project-row {
position: relative;
margin-inline: 1px;
border-radius: 8px;
background: var(--v2-background-bg-layer-01);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
transition: background-color 120ms;
}
.settings-project-row:hover {
background: var(--v2-background-bg-layer-02);
}
.settings-project-row-content {
display: flex;
width: 100%;
min-height: 73px;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 16px;
border-radius: inherit;
text-align: start;
}
.settings-project-row-name {
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
color: var(--v2-text-text-base);
}
.settings-tab-search-clear {
position: absolute;
top: 50%;
+3
View File
@@ -294,6 +294,8 @@ function RootSettings() {
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
active={surface.view().tab === "projects"}
autofocus={!surface.search.state.selected}
onOpenProject={(project) =>
surface.openProject({
server: ServerConnection.key(server),
@@ -376,6 +378,7 @@ function ServerSettings(props: { entry: SettingsServer }) {
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
active={surface.view().tab === "projects"}
onOpenProject={(project) =>
surface.openProject({
server: props.entry.key,
@@ -1,230 +0,0 @@
import { Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Icon } from "@opencode/ui/icon"
import { InlineInput } from "@opencode/ui/inline-input"
import { Menu } from "@opencode/ui/menu"
import { getFilename } from "@opencode/util/path"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import { useGlobal } from "@/runtime/server/runtime"
import { displayName, errorMessage } from "@/shell/layout/helpers"
import { fileManagerApp } from "@/home/projects/file-manager"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { showToast } from "@/shell/notifications/toast"
import type { LocalProject } from "@/shell/state/layout"
export function SettingsProjectRow(props: {
project: LocalProject
server: ServerConnection.Any
onOpen: (project: LocalProject) => void
}) {
const language = useLanguage()
const platform = usePlatform()
const global = useGlobal()
const [store, setStore] = createStore({
menu: undefined as { x: number; y: number } | undefined,
editor: undefined as { draft: string; saving: boolean } | undefined,
})
let row: HTMLDivElement | undefined
let button: HTMLButtonElement | undefined
let input: HTMLInputElement | undefined
let outside = false
const openMenu = (x: number, y: number) => {
if (!row) return
const bounds = row.getBoundingClientRect()
setStore("menu", { x: x - bounds.left, y: y - bounds.top })
}
const openEditor = () => {
setStore("editor", { draft: displayName(props.project), saving: false })
requestAnimationFrame(() => {
input?.focus()
input?.select()
})
}
const closeEditor = () => {
if (store.editor?.saving) return
setStore("editor", undefined)
}
const saveEditor = async () => {
if (!store.editor || store.editor.saving) return
const name = store.editor.draft.trim()
if (!name || name === displayName(props.project)) {
closeEditor()
requestAnimationFrame(() => button?.focus())
return
}
setStore("editor", "saving", true)
const context = global.ensureServerCtx(props.server)
const value = name === getFilename(props.project.worktree) ? "" : name
const saved = await (props.project.id && props.project.id !== "global"
? context.sdk.api.project
.update({ projectID: props.project.id, name: value })
.then((project) => context.sync.project.update(project))
: Promise.resolve(context.sync.project.meta(props.project.worktree, { name: value }))
)
.then(() => true)
.catch((error: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
const restore = document.activeElement === document.body || document.activeElement === input
if (saved) setStore("editor", undefined)
if (!saved) setStore("editor", "saving", false)
if (!restore) return
requestAnimationFrame(() => (saved ? button : input)?.focus())
}
return (
<div
ref={row}
data-component="settings-project-row"
class="settings-project-row group"
onContextMenu={(event) => {
if (store.editor) return
event.preventDefault()
openMenu(event.clientX, event.clientY)
}}
>
<Show
when={!store.editor}
fallback={
<div class="settings-project-row-content">
<ProjectRowContent project={props.project}>
<InlineInput
ref={input}
aria-label={language.t("common.rename")}
dir="auto"
value={store.editor?.draft ?? ""}
disabled={store.editor?.saving}
class="settings-project-row-name w-full outline-none"
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => setStore("editor", "draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
closeEditor()
requestAnimationFrame(() => button?.focus())
}}
onBlur={closeEditor}
/>
</ProjectRowContent>
</div>
}
>
<button
ref={button}
type="button"
aria-label={displayName(props.project)}
aria-haspopup="menu"
aria-expanded={!!store.menu}
class="settings-project-row-content"
onClick={() => props.onOpen(props.project)}
onKeyDown={(event) => {
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
event.preventDefault()
const bounds = event.currentTarget.getBoundingClientRect()
openMenu(bounds.left + 12, bounds.bottom)
}}
>
<ProjectRowContent project={props.project}>
<bdi class="settings-project-row-name truncate">{displayName(props.project)}</bdi>
</ProjectRowContent>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
/>
</button>
</Show>
<Menu
modal={false}
placement="bottom-start"
gutter={2}
open={!!store.menu}
onOpenChange={(open) => {
if (!open) setStore("menu", undefined)
}}
>
<Menu.Trigger
as="span"
aria-hidden="true"
tabIndex={-1}
class="pointer-events-none absolute size-px"
style={{ left: `${store.menu?.x ?? 0}px`, top: `${store.menu?.y ?? 0}px` }}
/>
<Menu.Portal>
<Menu.Content
onInteractOutside={() => {
outside = true
}}
onCloseAutoFocus={(event) => {
event.preventDefault()
const restore = !outside && !store.editor
outside = false
if (restore) requestAnimationFrame(() => button?.focus())
}}
>
<Menu.Item onSelect={openEditor}>{language.t("common.rename")}</Menu.Item>
<Show
when={platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)}
>
<Menu.Item
onSelect={() => {
if (!platform.openPath) return
void platform.openPath(props.project.worktree).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
}}
>
{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}
</Menu.Item>
</Show>
<Menu.Separator />
<Menu.Item
onSelect={() => {
const next = row?.nextElementSibling ?? row?.previousElementSibling
global.ensureServerCtx(props.server).projects.close(props.project.worktree)
requestAnimationFrame(() => next?.querySelector("button")?.focus())
}}
>
{language.t("common.close")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
)
}
function ProjectRowContent(props: { project: LocalProject; children: JSX.Element }) {
return (
<span class="flex items-start gap-2.5 min-w-0 flex-1">
<ProjectIcon project={props.project} class="shrink-0" />
<span class="flex min-w-0 flex-1 flex-col gap-1.5">
{props.children}
<bdi
dir="ltr"
class="text-11-regular leading-[var(--line-height-compact)] text-v2-text-text-muted truncate"
title={props.project.worktree}
>
{props.project.worktree}
</bdi>
</span>
</span>
)
}
@@ -1,43 +1,45 @@
import { Show, createEffect, createMemo, on, type Component } from "solid-js"
import { Key } from "@solid-primitives/keyed"
import { For, Show, createEffect, createMemo, on, onCleanup, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Icon } from "@opencode/ui/icon"
import { TextInput } from "@opencode/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import type { LocalProject } from "@/shell/state/layout"
import { SettingsSearchEmpty } from "../search-empty"
import { settingsProjects } from "../servers/inventory"
import { SettingsProjectRow } from "./project-row"
import "@/settings/search.css"
import "@/settings/settings.css"
export const SettingsProjects: Component<{
server: ServerConnection.Any
active?: boolean
autofocus?: boolean
onOpenProject: (project: LocalProject) => void
}> = (props) => {
const language = useLanguage()
const global = useGlobal()
const [store, setStore] = createStore({ filter: "", overflow: { start: false, end: false } })
const [store, setStore] = createStore({ filter: "" })
let search: HTMLInputElement | undefined
const updateOverflow = () => {
if (!search) return
const offset = Math.abs(search.scrollLeft)
setStore("overflow", {
start: offset > 1,
end: search.scrollWidth - search.clientWidth - offset > 1,
})
}
createEffect(on(() => store.filter, updateOverflow))
const projects = createMemo(() => settingsProjects(global.ensureServerCtx(props.server)))
const searchable = createMemo(() => projects().length > 7)
const filtered = createMemo(() => {
const query = searchable() ? store.filter.trim().toLowerCase() : ""
return query ? projects().filter((project) => displayName(project).toLowerCase().includes(query)) : projects()
})
createEffect(
on(
() => (props.active ?? true) && searchable(),
(active) => {
if (!active) return
const frame = requestAnimationFrame(() => {
if (props.active !== false && props.autofocus !== false && search?.isConnected)
search.focus({ preventScroll: true })
})
onCleanup(() => cancelAnimationFrame(frame))
},
),
)
createEffect(() => {
if (!searchable()) setStore("filter", "")
})
@@ -52,24 +54,16 @@ export const SettingsProjects: Component<{
</div>
</div>
<Show when={searchable()}>
<div class="settings-tab-search settings-projects-search">
<div class="settings-tab-search">
<TextInput
ref={(element) => {
search = element
createResizeObserver(element, updateOverflow)
}}
ref={search}
type="search"
appearance="base"
leadingIcon={<Icon name="magnifying-glass" size="small" />}
value={store.filter}
data-overflow-start={store.overflow.start}
data-overflow-end={store.overflow.end}
onScroll={updateOverflow}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.projects.search.placeholder")}
aria-label={language.t("settings.projects.search.placeholder")}
showClearButton={!!store.filter}
clearIcon="circle-xmark"
onClearClick={() => {
setStore("filter", "")
search?.focus({ preventScroll: true })
@@ -87,26 +81,32 @@ export const SettingsProjects: Component<{
<Show
when={filtered().length > 0}
fallback={
<Show
when={store.filter.trim()}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<div class="settings-projects-empty">
<SettingsSearchEmpty query={store.filter} />
</div>
</Show>
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<div class="flex w-full flex-col gap-2">
<Key each={filtered()} by="worktree">
<For each={filtered()}>
{(project) => (
<SettingsProjectRow project={project()} server={props.server} onOpen={props.onOpenProject} />
<button
type="button"
aria-label={displayName(project)}
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-[background-color] hover:bg-v2-background-bg-layer-01 text-start"
onClick={() => props.onOpenProject(project)}
>
<span class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectIcon project={project} class="shrink-0" />
<bdi class="text-13-medium text-v2-text-text-base truncate">{displayName(project)}</bdi>
</span>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
/>
</button>
)}
</Key>
</For>
</div>
</Show>
</div>
+9 -9
View File
@@ -2,7 +2,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode/client/effect/service"
import { ServerInfo } from "@opencode/protocol/groups/server"
import { ServerStatus } from "@opencode/protocol/groups/server"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
@@ -42,12 +42,12 @@ try {
const credential = btoa(`opencode:${info.password}`)
const headers = { authorization: "Basic " + credential }
const token = encodeURIComponent(credential)
const serverInfo = await waitForReady(info.url, headers)
if (serverInfo.pid !== info.pid) throw new Error("Server info does not match registration")
const tokenInfo = await fetch(new URL(`/api/info?auth_token=${token}`, info.url), {
const status = await waitForReady(info.url, headers)
if (status.pid !== info.pid) throw new Error("Status process does not match registration")
const tokenStatus = await fetch(new URL(`/api/status?auth_token=${token}`, info.url), {
signal: AbortSignal.timeout(5_000),
})
if (tokenInfo.status !== 200) throw new Error("Compiled service rejected query authentication")
if (tokenStatus.status !== 200) throw new Error("Compiled service rejected query authentication")
const tokenOpenApi = await fetch(new URL(`/openapi.json?auth_token=${token}`, info.url), {
signal: AbortSignal.timeout(5_000),
})
@@ -57,10 +57,10 @@ try {
await fs.writeFile(plugin, pluginSource())
await waitForPlugin(info.url, headers)
const unauthorizedInfo = await fetch(new URL("/api/info", info.url), {
const unauthorizedStatus = await fetch(new URL("/api/status", info.url), {
signal: AbortSignal.timeout(5_000),
})
if (unauthorizedInfo.status !== 401) throw new Error("Compiled service exposed info without authentication")
if (unauthorizedStatus.status !== 401) throw new Error("Compiled service exposed status without authentication")
const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), {
signal: AbortSignal.timeout(5_000),
})
@@ -128,11 +128,11 @@ async function waitForRegistration() {
async function waitForReady(url: string, headers: HeadersInit) {
const deadline = Date.now() + 20_000
while (Date.now() < deadline) {
const response = await fetch(new URL("/api/info", url), {
const response = await fetch(new URL("/api/status", url), {
headers,
signal: AbortSignal.timeout(1_000),
}).catch(() => undefined)
if (response?.ok) return Schema.decodeUnknownPromise(ServerInfo)(await response.json())
if (response?.ok) return Schema.decodeUnknownPromise(ServerStatus)(await response.json())
await Bun.sleep(25)
}
throw new Error("Compiled service did not become ready")
+1 -1
View File
@@ -15,7 +15,7 @@ export default Runtime.handler(
const urls = Option.isSome(input.url)
? [input.url.value]
: (yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.status(),
)).urls
const info = { urls, username: "opencode", password }
process.stdout.write(
+9 -44
View File
@@ -20,7 +20,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
const decode = Schema.decodeUnknownOption(Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
const empty: Info = {}
export const layer = Layer.effect(
@@ -29,14 +29,14 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const file = path.join(global.config, "cli.json")
const content = process.env.OPENCODE_CLI_CONFIG_CONTENT
? Option.getOrUndefined(decode(parseRecord(process.env.OPENCODE_CLI_CONFIG_CONTENT)))
: undefined
const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
if (text === undefined) return undefined
return parseRecord(text)
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return undefined
return Option.getOrUndefined(decodeRecord(value))
})
const write = Effect.fnUntraced(function* (text: string) {
@@ -61,9 +61,6 @@ export const layer = Layer.effect(
}),
),
)
const load = Effect.fnUntraced(function* (migration?: Info) {
return merge(migration ?? Option.getOrUndefined(decode(yield* readJson())), content)
})
const get = Effect.fn("cli.config.get")(() =>
withLock(
@@ -75,7 +72,8 @@ export const layer = Layer.effect(
)
if (migration?.cause)
yield* Effect.logWarning("failed to persist migrated cli config", { cause: migration.cause })
return yield* load(migration?.info)
if (migration?.info) return migration.info
return Option.getOrElse(decode(yield* readJson()), () => empty)
}),
),
)
@@ -85,7 +83,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const migration = yield* migrate
if (migration?.cause) return yield* Effect.failCause(migration.cause)
const current = yield* load(migration?.info)
const current = migration?.info ?? Option.getOrElse(decode(yield* readJson()), () => empty)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
@@ -104,7 +102,7 @@ export const layer = Layer.effect(
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return merge(config, content)
return config
}),
).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
)
@@ -115,39 +113,6 @@ export const layer = Layer.effect(
type Edit = { readonly path: (string | number)[]; readonly value: any }
function merge(...values: readonly (Info | undefined)[]) {
return Option.getOrElse(
decode(
values.reduce<Record<string, unknown>>(
(result, value) => mergeRecords(result, value ?? {}),
{},
),
),
() => empty,
)
}
function mergeRecords(base: object, overlay: object) {
return Object.entries(overlay).reduce<Record<string, unknown>>(
(result, [key, value]) => {
result[key] = isRecord(result[key]) && isRecord(value) ? mergeRecords(result[key], value) : value
return result
},
{ ...base },
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function parseRecord(text: string) {
const errors: ParseError[] = []
const value: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return undefined
return Option.getOrUndefined(decodeRecord(value))
}
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
if (Object.is(before, after)) return []
if (
@@ -29,7 +29,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
} satisfies Endpoint
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const health = yield* Effect.tryPromise({
try: () => client.server.info({ signal: AbortSignal.timeout(5_000) }),
try: () => client.server.status({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => connectError(endpoint, cause),
})
if (health.version !== OPENCODE_VERSION)
+2 -2
View File
@@ -272,7 +272,7 @@ function authServer(fetch: (request: Request, url: URL) => Response | Promise<Re
fetch(request) {
const url = new URL(request.url)
requests?.push(url.pathname)
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/model/default") return Response.json(located(null))
return fetch(request, url)
},
@@ -280,7 +280,7 @@ function authServer(fetch: (request: Request, url: URL) => Response | Promise<Re
}
function status() {
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } })
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
}
function located<T>(data: T) {
-45
View File
@@ -71,51 +71,6 @@ test("preserves the schema in an existing cli.json", async () => {
expect(await Bun.file(file).json()).toEqual(config)
})
test("merges inline CLI config content over the global config", async () => {
await using directory = await tmpdir()
const file = path.join(directory.path, "cli.json")
const previous = process.env.OPENCODE_CLI_CONFIG_CONTENT
await Bun.write(
file,
JSON.stringify({
tabs: { enabled: true, scope: "global" },
keybinds: { "app.exit": "ctrl+q" },
plugins: ["global"],
animations: true,
}),
)
process.env.OPENCODE_CLI_CONFIG_CONTENT = JSON.stringify({
tabs: { enabled: false },
keybinds: { "help.show": false },
plugins: ["inline"],
animations: false,
})
try {
const result = await run(
directory.path,
Effect.gen(function* () {
const service = yield* Config.Service
const loaded = yield* service.get()
const updated = yield* service.update((draft) => {
draft.animations = true
draft.mouse = false
})
return { loaded, updated }
}),
)
expect(result.loaded.tabs).toEqual({ enabled: false, scope: "global" })
expect(result.loaded.keybinds).toEqual({ "app.exit": "ctrl+q", "help.show": false })
expect(result.loaded.plugins).toEqual(["inline"])
expect(result.updated).toMatchObject({ animations: false, mouse: false })
expect(await Bun.file(file).json()).toMatchObject({ animations: true, mouse: false })
} finally {
if (previous === undefined) delete process.env.OPENCODE_CLI_CONFIG_CONTENT
else process.env.OPENCODE_CLI_CONFIG_CONTENT = previous
}
})
test("migrates tui and kv config into cli.json", async () => {
await using directory = await tmpdir()
await Bun.write(
+2 -7
View File
@@ -39,14 +39,9 @@ describe("debug config command", () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/info") {
if (url.pathname === "/api/status") {
healthProbes += 1
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
}
requested = url
authorization.push(request.headers.get("authorization"))
-1
View File
@@ -4,7 +4,6 @@ export function isolatedEnv(root: string, overrides: Record<string, string | und
return {
...process.env,
HOME: root,
OPENCODE_CLI_CONFIG_CONTENT: undefined,
OPENCODE_CONFIG_CONTENT: "{}",
OPENCODE_CONFIG_DIR: path.join(root, "config"),
OPENCODE_DB: path.join(root, "opencode.db"),
@@ -12,7 +12,7 @@ await Effect.runPromise(
command: [process.execPath, path.join(import.meta.dir, "../../src/index.ts"), "serve"],
})
const response = yield* Effect.promise(() =>
fetch(new URL("/api/info", endpoint.url), { headers: Service.headers(endpoint) }),
fetch(new URL("/api/status", endpoint.url), { headers: Service.headers(endpoint) }),
)
console.log(`STANDALONE_READY ${endpoint.pid} ${endpoint.url} ${response.status}`)
return yield* Effect.never
+6 -7
View File
@@ -42,8 +42,7 @@ const sanitizedTransfer = {
],
}
const status = () =>
Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } })
const status = () => Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
function run(args: string[], stdin?: string) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
@@ -61,7 +60,7 @@ test("export is raw by default and supports explicit sanitization", async () =>
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/status") return status()
if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
if (url.pathname === `/api/experimental/session/${info.id}/export`) {
sanitization.push(url.searchParams.get("sanitize") ?? "")
@@ -99,7 +98,7 @@ test("export requires a session outside an interactive terminal", async () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/location") {
return Response.json({
directory: "/project",
@@ -128,7 +127,7 @@ test("export reports a missing session without a stack trace", async () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/status") return status()
if (url.pathname === `/api/experimental/session/${sessionID}/export`) {
return Response.json(
{ _tag: "SessionNotFoundError", sessionID, message: `Session not found: ${sessionID}` },
@@ -159,7 +158,7 @@ test("import validates a file and sends it to the resolved location", async () =
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/location") {
return Response.json({
directory: root,
@@ -202,7 +201,7 @@ test("import reports an existing session without a stack trace", async () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/location") {
return Response.json({
directory: root,
+6 -26
View File
@@ -31,24 +31,14 @@ describe("mini command", () => {
const initial = Bun.serve({
port: 0,
fetch() {
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
},
})
const replacement = Bun.serve({
port: 0,
fetch(request) {
authorization.push(request.headers.get("authorization"))
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
},
})
const controller = new AbortController()
@@ -67,7 +57,7 @@ describe("mini command", () => {
})
const client = await connection.reconnect?.(controller.signal)
if (!client) throw new Error("Expected a replacement client")
await client.server.info()
await client.server.status()
expect(client).not.toBe(connection.sdk)
expect(signal).toBe(controller.signal)
@@ -157,13 +147,8 @@ describe("mini command", () => {
async fetch(request) {
const url = new URL(request.url)
requests.push(url.pathname)
if (url.pathname === "/api/info")
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
if (url.pathname === "/api/status")
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
if (url.pathname === "/api/location")
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
if (url.pathname === "/api/session") {
@@ -205,12 +190,7 @@ describe("mini command", () => {
port: 0,
fetch(request) {
if (new URL(request.url).pathname === "/api/session") return new Response("boom", { status: 500 })
return Response.json({
version: "incompatible",
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
return Response.json({ version: "incompatible", pid: process.pid, urls: [] })
},
})
+4 -9
View File
@@ -256,15 +256,13 @@ test("concurrent service processes elect one server", async () => {
expect((await Bun.file(config).json()).password).toBe(info.password)
expect(await Bun.file(registration + ".lock").exists()).toBe(false)
expect(
await fetch(new URL("/api/info", info.url), {
await fetch(new URL("/api/status", info.url), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
}).then((response) => response.json()),
).toEqual({
version: info.version,
pid: info.pid,
urls: [info.url],
// The server reports the canonical tmp directory; Windows os.tmpdir() can be an 8.3 short name.
paths: { tmp: await fs.realpath(path.join(os.tmpdir(), "opencode")) },
})
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
try {
@@ -339,7 +337,7 @@ test.each([
const info = await waitForInfo(registration)
await Promise.all(
[...new Set([...cors, ...origins, "https://unlisted.example.com"])].map(async (origin) => {
const response = await fetch(new URL("/api/info", info.url), {
const response = await fetch(new URL("/api/status", info.url), {
method: "OPTIONS",
headers: { Origin: origin, "Access-Control-Request-Method": "GET" },
})
@@ -442,10 +440,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
fetch() {
requests.count += 1
if (requests.count === 2) recognizing.resolve()
return Response.json(
{ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } },
{ status: 503 },
)
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] }, { status: 503 })
},
})
const registration = path.join(root, "state", "opencode", "service-local.json")
@@ -580,7 +575,7 @@ async function waitForInfo(file: string, accept: (info: Info) => boolean = () =>
async function waitForFailed(info: Info) {
for (let attempt = 0; attempt < 400; attempt++) {
const status = await fetch(new URL("/api/info", info.url), {
const status = await fetch(new URL("/api/status", info.url), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
})
.then((response) => response.status)
+4 -14
View File
@@ -35,13 +35,8 @@ describe("web UI", () => {
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const pathname = new URL(request.url, "http://localhost").pathname
if (pathname === "/api/info")
return HttpServerResponse.jsonUnsafe({
version: "test",
pid: 1,
urls: [origin],
paths: { tmp: "/tmp/opencode" },
})
if (pathname === "/api/status")
return HttpServerResponse.jsonUnsafe({ version: "test", pid: 1, urls: [origin] })
return yield* Effect.fail(
new HttpServerError.HttpServerError({
reason: new HttpServerError.RouteNotFound({ request }),
@@ -52,13 +47,8 @@ describe("web UI", () => {
)
const origin = HttpServer.formatAddress(http.address)
const status = yield* Effect.promise(() => fetch(`${origin}/api/info`))
expect(yield* Effect.promise(() => status.json())).toEqual({
version: "test",
pid: 1,
urls: [origin],
paths: { tmp: "/tmp/opencode" },
})
const status = yield* Effect.promise(() => fetch(`${origin}/api/status`))
expect(yield* Effect.promise(() => status.json())).toEqual({ version: "test", pid: 1, urls: [origin] })
const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`))
expect(missing.status).toBe(404)
+3 -16
View File
@@ -39,28 +39,23 @@ import type { Vcs } from "@opencode/schema/vcs"
import type { WebSearch } from "@opencode/schema/websearch"
import type { Config } from "@opencode/schema/config"
export type ServerInfoOutput = {
export type ServerStatusOutput = {
readonly version: string
readonly pid: number
readonly urls: ReadonlyArray<string>
readonly paths: { readonly tmp: string }
}
export type ServerInfoOperation<E = never> = () => Effect.Effect<ServerInfoOutput, E>
export type ServerStatusOperation<E = never> = () => Effect.Effect<ServerStatusOutput, E>
export interface ServerApi<E = never> {
readonly info: ServerInfoOperation<E>
readonly status: ServerStatusOperation<E>
}
export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type LocationGetOutput = Location.PublicInfo
export type LocationGetOperation<E = never> = (input?: LocationGetInput) => Effect.Effect<LocationGetOutput, E>
export type LocationReloadOutput = void
export type LocationReloadOperation<E = never> = () => Effect.Effect<LocationReloadOutput, E>
export interface LocationApi<E = never> {
readonly get: LocationGetOperation<E>
readonly reload: LocationReloadOperation<E>
}
export type AgentListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
@@ -1823,17 +1818,9 @@ export type FileFindInput = {
export type FileFindOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<FileSystem.Entry> }
export type FileFindOperation<E = never> = (input: FileFindInput) => Effect.Effect<FileFindOutput, E>
export type FileWriteInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly payload: FileSystem.WriteInput
}
export type FileWriteOutput = { readonly location: Location.PublicRef; readonly data: FileSystem.Write }
export type FileWriteOperation<E = never> = (input: FileWriteInput) => Effect.Effect<FileWriteOutput, E>
export interface FileApi<E = never> {
readonly list: FileListOperation<E>
readonly find: FileFindOperation<E>
readonly write: FileWriteOperation<E>
}
export type CommandListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
+6 -27
View File
@@ -5,10 +5,9 @@ import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../../contract"
import type {
ServerInfoOutput,
ServerStatusOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
AgentListInput,
AgentListOutput,
AgentGetInput,
@@ -179,8 +178,6 @@ import type {
FileListOutput,
FileFindInput,
FileFindOutput,
FileWriteInput,
FileWriteOutput,
CommandListInput,
CommandListOutput,
SkillListInput,
@@ -280,23 +277,17 @@ const preserveStream =
<E, R>(stream: Stream.Stream<A, E, R>) =>
stream
const EndpointServerInfo = (raw: RawClient["server.server"]) => () =>
preserveEffect<ServerInfoOutput>()(raw["server.info"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointServerStatus = (raw: RawClient["server.server"]) => () =>
preserveEffect<ServerStatusOutput>()(raw["server.status"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ info: EndpointServerInfo(raw) })
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ status: EndpointServerStatus(raw) })
const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) =>
preserveEffect<LocationGetOutput>()(
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointLocationReload = (raw: RawClient["server.location"]) => () =>
preserveEffect<LocationReloadOutput>()(raw["location.reload"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroupLocation = (raw: RawClient["server.location"]) => ({
get: EndpointLocationGet(raw),
reload: EndpointLocationReload(raw),
})
const adaptGroupLocation = (raw: RawClient["server.location"]) => ({ get: EndpointLocationGet(raw) })
const EndpointAgentList = (raw: RawClient["server.agent"]) => (input?: AgentListInput) =>
preserveEffect<AgentListOutput>()(
@@ -1141,19 +1132,7 @@ const EndpointFileFind = (raw: RawClient["server.fs"]) => (input: FileFindInput)
}).pipe(Effect.mapError(mapClientError)),
)
type FileWriteRequest = Parameters<RawClient["server.fs"]["fs.write"]>[0]
const EndpointFileWrite = (raw: RawClient["server.fs"]) => (input: FileWriteInput) =>
preserveEffect<FileWriteOutput>()(
raw["fs.write"]({ query: { location: input["location"] }, payload: input["payload"] } as FileWriteRequest).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupFile = (raw: RawClient["server.fs"]) => ({
list: EndpointFileList(raw),
find: EndpointFileFind(raw),
write: EndpointFileWrite(raw),
})
const adaptGroupFile = (raw: RawClient["server.fs"]) => ({ list: EndpointFileList(raw), find: EndpointFileFind(raw) })
const EndpointCommandList = (raw: RawClient["server.command"]) => (input?: CommandListInput) =>
preserveEffect<CommandListOutput>()(
+8 -8
View File
@@ -172,7 +172,7 @@ export const Info = Schema.Struct({
})
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeInfo = Schema.decodeUnknownOption(
const decodeStatus = Schema.decodeUnknownOption(
Schema.Struct({
version: Schema.String,
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
@@ -213,7 +213,7 @@ const probeResult = Effect.fnUntraced(function* (
} satisfies Endpoint
const signal = AbortSignal.timeout(timeout)
const result = yield* Effect.promise(() =>
fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
fetch(new URL("/api/status", info.url), { headers: headers(endpoint), signal })
.then(async (response) => ({
response,
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
@@ -225,7 +225,7 @@ const probeResult = Effect.fnUntraced(function* (
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
// The previous V2 service exposes /api/status instead. Its authenticated 404 is enough
// The previous V2 service exposes /api/health instead. Its authenticated 404 is enough
// to recognize the registered daemon as incompatible and route it through replacement.
if (response.status === 404)
return {
@@ -239,16 +239,16 @@ const probeResult = Effect.fnUntraced(function* (
timedOut: false,
}
const body = result.value.body
const serverInfo = decodeInfo(body)
if (Option.isSome(serverInfo)) {
if (serverInfo.value.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && serverInfo.value.version !== info.version)
const status = decodeStatus(body)
if (Option.isSome(status)) {
if (status.value.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && status.value.version !== info.version)
return { service: undefined, timedOut: false }
return {
service: {
info,
endpoint,
version: serverInfo.value.version,
version: status.value.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
compatible: true,
} satisfies LocalService,
@@ -1,8 +1,7 @@
import type {
ServerInfoOutput,
ServerStatusOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
AgentListInput,
AgentListOutput,
AgentGetInput,
@@ -175,8 +174,6 @@ import type {
FileListOutput,
FileFindInput,
FileFindOutput,
FileWriteInput,
FileWriteOutput,
CommandListInput,
CommandListOutput,
SkillListInput,
@@ -400,9 +397,9 @@ export function make(options: ClientOptions) {
return {
server: {
info: (requestOptions?: RequestOptions) =>
request<ServerInfoOutput>(
{ method: "GET", path: `/api/info`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
status: (requestOptions?: RequestOptions) =>
request<ServerStatusOutput>(
{ method: "GET", path: `/api/status`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
},
@@ -419,17 +416,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
reload: (requestOptions?: RequestOptions) =>
request<LocationReloadOutput>(
{
method: "POST",
path: `/api/location/reload`,
successStatus: 204,
declaredStatuses: [400, 401, 503],
empty: true,
},
requestOptions,
),
},
agent: {
list: (input?: AgentListInput, requestOptions?: RequestOptions) =>
@@ -1568,19 +1554,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
write: (input: FileWriteInput, requestOptions?: RequestOptions) =>
request<FileWriteOutput>(
{
method: "POST",
path: `/api/experimental/fs/write`,
query: { location: input["location"] },
body: input["payload"],
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
command: {
list: (input?: CommandListInput, requestOptions?: RequestOptions) =>
+10 -31
View File
@@ -1,6 +1,6 @@
export type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue }
export type ServerInfo = { version: string; pid: number; urls: Array<string>; paths: { tmp: string } }
export type ServerStatus = { version: string; pid: number; urls: Array<string> }
export type LocationPublicInfo = { directory: string; project: { id: string; directory: string; canonical: string } }
@@ -319,8 +319,6 @@ export type PermissionSavedInfo = {
export type FileSystemEntry = { path: string; type: "file" | "directory" }
export type FileSystemWrite = { path: string }
export type CommandInfo = { name: string; description?: string }
export type SkillInfo = {
@@ -822,15 +820,6 @@ export type SessionUsageRecorded = {
data: { sessionID: string; source: "title" | "compaction"; cost: MoneyUSD; tokens: TokenUsageInfo }
}
export type LocationShutdown = {
id: string
created: number
metadata?: { [x: string]: any }
type: "location.shutdown"
location?: LocationRef
data: {}
}
export type ModelsDevRefreshed = {
id: string
created: number
@@ -2332,7 +2321,6 @@ export type IntegrationInfo = {
}
export type V2Event =
| LocationShutdown
| ModelsDevRefreshed
| CredentialUpdated
| CredentialSwitched
@@ -2441,14 +2429,6 @@ export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly m
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type AgentNotFoundError = {
readonly _tag: "AgentNotFoundError"
readonly agentID: string
@@ -2457,6 +2437,14 @@ export type AgentNotFoundError = {
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2657,7 +2645,7 @@ export type WorktreeError = {
export const isWorktreeError = (value: unknown): value is WorktreeError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
export type ServerInfoOutput = ServerInfo
export type ServerStatusOutput = ServerStatus
export type LocationGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
@@ -2665,8 +2653,6 @@ export type LocationGetInput = {
export type LocationGetOutput = LocationPublicInfo
export type LocationReloadOutput = void
export type AgentListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
@@ -5795,13 +5781,6 @@ export type FileFindInput = {
export type FileFindOutput = { location: LocationPublicRef; data: Array<FileSystemEntry> }
export type FileWriteInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly payload: { readonly path: string; readonly data: string }
}
export type FileWriteOutput = { location: LocationPublicRef; data: FileSystemWrite }
export type CommandListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
+8 -9
View File
@@ -163,7 +163,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const signal = AbortSignal.timeout(timeout)
const result = await fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
const result = await fetch(new URL("/api/status", info.url), { headers: headers(endpoint), signal })
.then(async (response) => ({
response,
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
@@ -174,7 +174,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
// The previous V2 service exposes /api/status instead. Its authenticated 404 is enough
// The previous V2 service exposes /api/health instead. Its authenticated 404 is enough
// to recognize the registered daemon as incompatible and route it through replacement.
if (response.status === 404)
return {
@@ -187,16 +187,15 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
} satisfies LocalService,
timedOut: false,
}
const serverInfo = decodeInfo(result.value.body)
if (serverInfo !== undefined) {
if (serverInfo.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && serverInfo.version !== info.version)
return { service: undefined, timedOut: false }
const status = decodeStatus(result.value.body)
if (status !== undefined) {
if (status.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && status.version !== info.version) return { service: undefined, timedOut: false }
return {
service: {
info,
endpoint,
version: serverInfo.version,
version: status.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
compatible: true,
} satisfies LocalService,
@@ -206,7 +205,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
return { service: undefined, timedOut: false }
}
function decodeInfo(input: unknown) {
function decodeStatus(input: unknown) {
if (typeof input !== "object" || input === null) return
if (!("version" in input) || typeof input.version !== "string") return
if (!("pid" in input) || typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid < 0) return
-6
View File
@@ -592,12 +592,6 @@ export function createData(config: CreateDataInput) {
function handleEvent(event: OpenCodeEvent) {
switch (event.type) {
case "location.shutdown": {
if (!event.location) return
result.location.invalidate(event.location)
refresh(() => result.location.sync(event.location))
return
}
case "server.connected": {
const updates = new Map<string, DataSessionStatus | undefined>()
activeUpdates = updates
+4 -14
View File
@@ -15,31 +15,21 @@ import {
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
test("server.info decodes the readiness response", async () => {
test("server.status decodes the readiness response", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({
version: "current",
pid: 123,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
}),
Response.json({ version: "current", pid: 123, urls: ["http://localhost:3000"] }),
),
),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.server.info()
return yield* client.server.status()
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result).toEqual({
version: "current",
pid: 123,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
})
expect(result).toEqual({ version: "current", pid: 123, urls: ["http://localhost:3000"] })
})
test("vcs.base decodes nullable review-base metadata", async () => {
+4 -15
View File
@@ -52,7 +52,7 @@ const server = Bun.serve({
await writeFile(registration + ".prepared", JSON.stringify(handoff))
return Response.json({ handoff })
}
if (pathname !== "/api/info") return new Response(null, { status: 404 })
if (pathname !== "/api/status") return new Response(null, { status: 404 })
requests += 1
if (mode === "starting") await writeFile(registration + ".status-request", "")
if (mode === "hanging") {
@@ -65,21 +65,10 @@ const server = Bun.serve({
return new Response(null, { status: 503 })
}
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json(
{ version, pid: process.pid, urls: [server.url.toString()], paths: { tmp: "/tmp/opencode" } },
{ status: 503 },
)
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] }, { status: 503 })
if (mode === "failed-owner")
return Response.json(
{ version, pid: process.pid, urls: [server.url.toString()], paths: { tmp: "/tmp/opencode" } },
{ status: 500 },
)
return Response.json({
version,
pid: process.pid,
urls: [server.url.toString()],
paths: { tmp: "/tmp/opencode" },
})
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] }, { status: 500 })
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] })
},
})
+8 -24
View File
@@ -192,29 +192,19 @@ test("websearch.query uses the public HTTP contract", async () => {
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
})
test("server.info uses the public HTTP contract", async () => {
test("server.status uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({
version: "2.0.0",
pid: 1,
urls: ["http://192.168.1.10:4096"],
paths: { tmp: "/tmp/opencode" },
})
return Response.json({ version: "2.0.0", pid: 1, urls: ["http://192.168.1.10:4096"] })
},
})
expect(await client.server.info()).toEqual({
version: "2.0.0",
pid: 1,
urls: ["http://192.168.1.10:4096"],
paths: { tmp: "/tmp/opencode" },
})
expect(await client.server.status()).toEqual({ version: "2.0.0", pid: 1, urls: ["http://192.168.1.10:4096"] })
expect(request?.method).toBe("GET")
expect(request?.url).toBe("http://localhost:3000/api/info")
expect(request?.url).toBe("http://localhost:3000/api/status")
})
test("experimental wellknown integration add uses the public HTTP contract", async () => {
@@ -696,7 +686,7 @@ test("event.subscribe reports heartbeat comments as stream activity", async () =
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event transport passes through ordinary info requests", async () => {
test("event transport passes through ordinary status requests", async () => {
const requests: string[] = []
const event = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
const client = OpenCode.make({
@@ -709,22 +699,16 @@ test("event transport passes through ordinary info requests", async () => {
headers: { "content-type": "text/event-stream" },
})
}
return Response.json({
version: "2.0.0",
pid: 1,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
})
return Response.json({ version: "2.0.0", pid: 1, urls: ["http://localhost:3000"] })
},
})
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
await expect(client.server.info()).resolves.toEqual({
await expect(client.server.status()).resolves.toEqual({
version: "2.0.0",
pid: 1,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
})
expect(requests).toEqual(["/api/event", "/api/info"])
expect(requests).toEqual(["/api/event", "/api/status"])
})
test("event.subscribe terminates on malformed Promise SSE data", async () => {
+1 -1
View File
@@ -300,5 +300,5 @@ function run<A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) {
}
async function status(url: string) {
return fetch(new URL("/api/info", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
return fetch(new URL("/api/status", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
}
+1 -6
View File
@@ -182,8 +182,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
non-callable values are not constructors. Error constructors take the ES2022 options object, so
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
non-callable values are not constructors.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -473,10 +472,6 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
plain data).
- [x] An Error crosses, in either direction, as its name, message, `cause`, and own enumerable data, so Node's
`code`, `errno`, `syscall`, and `path` reach the program and `err.code === "ENOENT"` works. `stack` stays on its
own side, no field may shadow an Error method, and a field that cannot cross (a class instance, a function) is
left behind rather than replacing the error.
- [ ] Program functions as arguments to extension code (callbacks such as `forEach`).
- [ ] Host classes. Stateful host objects are expressed as closures; a declared method table would be the next
step if `new X()` in a program is ever needed.
+4 -12
View File
@@ -6,7 +6,7 @@ import { type AstNode, formatLocation, PendingThrow, Throw, sourceLocation, type
import { containsRuntimeReference } from "./references.js"
import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js"
import { constructor, methods, prototypeFrom, receiver } from "./native.js"
import { type Callable, define, get, has, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
import { type Callable, define, get, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
import type { Interpreter } from "./interpreter.js"
import { formatValue } from "../stdlib/console.js"
import { coerceToString } from "../stdlib/value.js"
@@ -146,17 +146,9 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
const prototype = builtins[type]
const construct = (args: Array<unknown>, newTarget: Callable) => {
const proto = prototypeFrom(newTarget, prototype)
const created =
type === "AggregateError"
? constructAggregateErrorValue(ctx, args, proto)
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
// ES2022 `new Error(message, { cause })`: installed only when the options object has the property at all.
const options = args[type === "AggregateError" ? 2 : 1]
if (!(options instanceof Obj) || !has(options, "cause")) return created
return Effect.map(created, (value) => {
define(value, "cause", get(options, "cause"), hidden)
return value
})
return type === "AggregateError"
? constructAggregateErrorValue(ctx, args, proto)
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
}
const ctor: Native<R> = constructor<R>(builtins, prototype, {
name: type,
@@ -5,16 +5,13 @@ import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
import type { Interpreter } from "./interpreter.js"
import { createErrorValue, isErrorType } from "./intrinsics.js"
import { MAX_VALUE_DEPTH } from "./limits.js"
import { PendingThrow, Throw, typeError } from "./model.js"
import { Throw, typeError } from "./model.js"
import { fn } from "./native.js"
import {
Callable,
define,
entries,
get,
has,
hidden,
keys,
Arr,
Bytes,
DateObj,
@@ -63,28 +60,14 @@ export const extensionGlobals = <R>(
) {
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
}
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
seen.add(value)
if (value instanceof ErrorObj) {
const name = coerceToString(get(value, "name"))
const message = get(value, "message")
const text = message === undefined ? "" : coerceToString(message)
const copied =
name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
for (const key of new Set(["cause", ...keys(value)])) {
if (uncrossed.has(key) || !has(value, key)) continue
const item = crossing(() => next(get(value, key)))
if (item === left) continue
Object.defineProperty(copied, key, {
value: item,
writable: true,
configurable: true,
enumerable: key !== "cause",
})
}
seen.delete(value)
return copied
return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
}
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
seen.add(value)
const copied =
value instanceof Arr
? value.items.map(next)
@@ -102,29 +85,18 @@ export const extensionGlobals = <R>(
if (isPrimitive(value)) return value
if (typeof value === "function") return wrap(value, label)
if (value !== null && typeof value === "object") {
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
if (value instanceof Date) return new DateObj(builtins.Date, value.getTime())
if (value instanceof RegExp) return new RegExpObj(builtins.RegExp, value.source, value.flags)
if (value instanceof Uint8Array) return new Bytes(builtins.Uint8Array, new Uint8Array(value))
if (value instanceof ArrayBuffer) return new Bytes(builtins.Uint8Array, new Uint8Array(value.slice(0)))
if (value instanceof Error) {
if (seen.has(value)) throw typeError(`${label} produced a circular value.`)
seen.add(value)
const copied = createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message)
const fields = value as unknown as Record<string, unknown>
for (const key of new Set(["cause", ...Object.keys(value)])) {
if (uncrossed.has(key) || !(key in value) || typeof fields[key] === "function") continue
const item = crossing(() => next(fields[key], `${label}.${key}`))
if (item === left) continue
define(copied, key, item, key === "cause" ? hidden : undefined)
}
seen.delete(value)
return copied
return createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message)
}
if (value instanceof URL) return new URLObj(builtins.URL, builtins.URLSearchParams, new URL(value.href))
if (value instanceof URLSearchParams) {
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
}
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
if (value instanceof Map) {
const wrapped = new MapObj(builtins.Map)
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
@@ -188,22 +160,6 @@ export const extensionGlobals = <R>(
)
}
/**
* An error crosses as its name, message, `cause`, and own enumerable fields, such as Node's `code`, `errno`,
* `syscall`, and `path`. `stack` stays on its own side, and no field may shadow an Error method. A field that cannot
* cross (a socket, a handle, a function) is left behind so the error itself always arrives.
*/
const uncrossed = new Set(["stack", "constructor", "toString", "__proto__"])
const left = Symbol("left behind")
const crossing = (convert: () => unknown): unknown => {
try {
return convert()
} catch (reason) {
if (reason instanceof PendingThrow) return left
throw reason
}
}
const hostErrors = new Map<string, ErrorConstructor>([
["TypeError", TypeError],
["RangeError", RangeError],
+1 -58
View File
@@ -163,26 +163,12 @@ describe("values are converted at the boundary, never shared", () => {
expect(held[0]).toEqual({ a: 1 })
})
test("a program Error crosses as a host Error with its name, message, cause, and own data", async () => {
test("a program Error crosses as a host Error with its name and message", async () => {
held.length = 0
await value(`keep(new TypeError("bad"))`)
expect(held[0]).toBeInstanceOf(TypeError)
expect((held[0] as Error).message).toBe("bad")
expect(Object.keys(held[0] as object)).toEqual([])
held.length = 0
await value(`
const e = new Error("m", { cause: new RangeError("root") })
e.code = "ENOENT"; e.detail = { path: "x" }
e.stack = "chosen"; e.toString = 1; e.constructor = 2; e.fn = () => 1
keep(e)`)
const crossed = held[0] as Error & Record<string, unknown>
expect(crossed.cause).toBeInstanceOf(RangeError)
expect((crossed.cause as Error).message).toBe("root")
expect(Object.keys(crossed)).toEqual(["code", "detail"])
expect(crossed.detail).toEqual({ path: "x" })
expect(crossed.stack).not.toBe("chosen")
expect(String(crossed)).toBe("Error: m")
expect(crossed.constructor).toBe(Error)
})
test("an Error with an unknown name crosses as a plain Error", async () => {
@@ -245,49 +231,6 @@ describe("host errors", () => {
])
})
test("a host Error arrives with its cause and own data; what cannot cross is left behind", async () => {
class Handle {}
const target = CodeMode.make({
extensions: [
Extension.make({
name: "fs",
globals: {
open: () => {
const error = Object.assign(new Error("ENOENT: no such file or directory, open 'x'"), {
code: "ENOENT",
errno: -2,
path: "x",
detail: { retried: true },
handle: new Handle(),
retry: () => 1,
})
throw new Error("open failed", { cause: error })
},
},
}),
],
})
expect(
await value(
`try { open() } catch (e) {
const c = e.cause
return [e.message, Object.keys(e), c instanceof Error, c.code, c.errno, c.path, c.detail, Object.keys(c), "stack" in c]
}`,
target,
),
).toEqual([
"open failed",
[],
true,
"ENOENT",
-2,
"x",
{ retried: true },
["code", "errno", "path", "detail"],
false,
])
})
test("a thrown or rejected value crosses like a return, so the program catches what was thrown", async () => {
const reason = { status: 404, nested: { a: 1 } }
const target = CodeMode.make({
+8 -8
View File
@@ -235,7 +235,7 @@ describe("OpenAPI.fromSpec", () => {
path: "/api/fs/read/*",
reason: "binary responses are not supported",
})
expect(toolAt(result.tools, "server.info")).not.toBeUndefined()
expect(toolAt(result.tools, "server.status")).not.toBeUndefined()
expect(toolAt(result.tools, "session.get")).not.toBeUndefined()
expect(toolAt(result.tools, "session.create")).not.toBeUndefined()
@@ -978,10 +978,10 @@ describe("OpenAPI.fromSpec", () => {
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
const info = toolAt(result.tools, "server.info")
const infoInput = Tool.isTool(info) && isRecord(info.input) ? info.input : undefined
expect(infoInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(infoInput) ? infoInput : {}
const status = toolAt(result.tools, "server.status")
const statusInput = Tool.isTool(status) && isRecord(status.input) ? status.input : undefined
expect(statusInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(statusInput) ? statusInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
})
@@ -994,7 +994,7 @@ describe("OpenAPI.fromSpec", () => {
runtime
.execute(
`
return search({ query: "server info", namespace: "opencode", limit: 1 })
return search({ query: "server status", namespace: "opencode", limit: 1 })
`,
)
.pipe(Effect.provide(layer)),
@@ -1005,8 +1005,8 @@ describe("OpenAPI.fromSpec", () => {
expect(result.value).toMatchObject({
items: [
{
path: "tools.opencode.server.info",
description: "Return the server identity, connection URLs, paths, and readiness status.",
path: "tools.opencode.server.status",
description: "Return the server identity, connection URLs, and readiness status.",
},
],
})
-10
View File
@@ -350,16 +350,6 @@ describe("Error values and instanceof", () => {
expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false)
})
test("new Error(message, { cause }) installs a non-enumerable cause only when the option is present", async () => {
expect(
await value(`
const inner = new Error("root")
const e = new TypeError("m", { cause: inner })
const agg = new AggregateError([], "a", { cause: 3 })
return [e.cause === inner, Object.keys(e), "cause" in new Error("m"), "cause" in new Error("m", { cause: undefined }), agg.cause]`),
).toEqual([true, [], false, true, 3])
})
test("thrown errors keep instanceof through try/catch", async () => {
expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([
true,
+3 -12
View File
@@ -5,9 +5,9 @@ import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "@opencode/util/fs-util"
import { Location } from "./location.js"
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
import { PositiveInt, RelativePath } from "./schema.js"
import { FileSystemSearch } from "./filesystem/search.js"
import { Entry, FileSystem, FindInput, Write, WriteInput } from "@opencode/schema/filesystem"
import { Entry, FileSystem, FindInput } from "@opencode/schema/filesystem"
export { Entry, Match, Submatch } from "@opencode/schema/filesystem"
export const ReadInput = Schema.Struct({
@@ -33,7 +33,7 @@ export const ListInput = Schema.Struct({
})
export type ListInput = typeof ListInput.Type
export { FindInput, Write, WriteInput }
export { FindInput }
export const DEFAULT_SEARCH_LIMIT = 100
export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
@@ -62,8 +62,6 @@ export interface Interface {
) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, NotFoundError>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
/** Writes a file at an absolute path or one relative to the location; not confined to it. */
readonly write: (input: WriteInput) => Effect.Effect<Write>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
@@ -145,13 +143,6 @@ const baseLayer = Layer.effect(
),
)
}),
// Unlike read, write reaches outside the location so clients can stage files in the
// server tmp directory, which the model is already told to prefer and permitted to access.
write: Effect.fn("FileSystem.write")(function* (input) {
const target = path.resolve(location.directory, input.path)
yield* fs.writeWithDirs(target, Buffer.from(input.data, "base64")).pipe(Effect.orDie)
return Write.make({ path: AbsolutePath.make(target) })
}),
})
}),
)
+9 -14
View File
@@ -79,7 +79,6 @@ export interface ListInput {
}
export interface Interface {
readonly close: Effect.Effect<void>
readonly create: (input: CreateInput) => Effect.Effect<Info, AlreadyExistsError | InvalidFormError>
readonly ask: (input: CreateInput) => Effect.Effect<TerminalState, AlreadyExistsError | InvalidFormError>
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
@@ -101,7 +100,6 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
let closed = false
const forms = yield* Cache.makeWith<ID, Entry>(
() => Effect.die(new Error("Form cache must be used via set/getSuccess, never get")),
{
@@ -139,7 +137,6 @@ export const layer = Layer.effect(
}
yield* Cache.set(forms, id, entry)
yield* bus.publish(Form.Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
if (closed) yield* cancel(id).pipe(Effect.orDie)
return form
}),
),
@@ -205,21 +202,19 @@ export const layer = Layer.effect(
),
)
const close = Effect.sync(() => {
closed = true
}).pipe(
Effect.andThen(Cache.values(forms)),
Effect.flatMap((entries) =>
Effect.forEach(
Array.from(entries).filter((entry) => entry.state.status === "pending"),
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
{ discard: true },
yield* Effect.addFinalizer(() =>
Cache.values(forms).pipe(
Effect.flatMap((entries) =>
Effect.forEach(
Array.from(entries).filter((entry) => entry.state.status === "pending"),
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
{ discard: true },
),
),
),
)
yield* Effect.addFinalizer(() => close)
return Service.of({ create, ask, get, list, state, reply, cancel, close })
return Service.of({ create, ask, get, list, state, reply, cancel })
}),
)
+1 -4
View File
@@ -1,4 +1,4 @@
import { Context, Effect, Layer } from "effect"
import { Effect, Layer } from "effect"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
import { Model } from "./model.js"
@@ -18,7 +18,6 @@ import { Image } from "./image.js"
import { LocationWatcher } from "./filesystem/location-watcher.js"
import { Integration } from "./integration.js"
import { Location } from "./location.js"
import { LocationLifecycle } from "./location-lifecycle.js"
import { FileAccess } from "./file-access.js"
import { ModelResolver } from "./model-resolver.js"
import { Mcp } from "./mcp/index.js"
@@ -58,7 +57,6 @@ export { Service, node, type Interface } from "./instance/service.js"
const nodes = [
Location.node,
LocationLifecycle.node,
Environment.node,
Config.node,
Agent.node,
@@ -159,7 +157,6 @@ export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Ser
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
// Instance boot failures are defects; provided operations retain their typed errors.
Layer.orDie,
Layer.tap((context) => Effect.addFinalizer(() => Context.get(context, LocationLifecycle.Service).shutdown)),
Layer.tap(() =>
Effect.logInfo("location services booted", {
directory: ref.directory,
-52
View File
@@ -1,52 +0,0 @@
export * as LocationLifecycle from "./location-lifecycle.js"
import { Context, Effect, Layer } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { LocationEvent } from "@opencode/schema/location-event"
import { Bus } from "./bus.js"
import { Form } from "./form.js"
import { Location } from "./location.js"
import { Permission } from "./permission.js"
import { Rpc } from "./rpc.js"
export class Service extends Context.Service<
Service,
{ readonly isClosed: () => boolean; readonly shutdown: Effect.Effect<void> }
>()("@opencode/LocationLifecycle") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
const forms = yield* Form.Service
const rpc = yield* Rpc.Service
let closed = false
const shutdown = yield* Effect.cached(
Effect.gen(function* () {
closed = true
yield* permission.close
yield* forms.close
yield* rpc.close
yield* bus.publish(
LocationEvent.Shutdown,
{},
{
location: Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
},
)
}).pipe(Effect.uninterruptible),
)
return Service.of({
isClosed: () => closed,
shutdown,
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Location.node, Permission.node, Form.node, Rpc.node],
})
+1 -19
View File
@@ -1,4 +1,4 @@
import { Context, Effect, Exit, Layer, LayerMap, RcMap } from "effect"
import { Context, Effect, Layer, LayerMap } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Node } from "@opencode/util/effect/app-node"
import { AbsolutePath } from "@opencode/schema/schema"
@@ -17,24 +17,6 @@ export class Service extends Context.Service<
export const node = LayerNode.unbound(Service, Node.tags.values.global)
export const reload = Effect.fn("LocationServiceMap.reload")(function* () {
const locations = yield* Service
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
yield* Effect.forEach(refs, (ref) => locations.invalidate(ref), {
discard: true,
concurrency: "unbounded",
})
// Boot every replacement now and let all builds settle even if one fails.
const results = yield* Effect.forEach(
refs,
(ref) => Effect.scoped(locations.contextEffect(ref)).pipe(Effect.asVoid, Effect.exit),
{ concurrency: "unbounded" },
)
const failure = results.find(Exit.isFailure)
if (failure) return yield* Effect.failCause(failure.cause)
yield* Effect.logInfo("location services reloaded", { count: refs.length })
})
/** Normalize equivalent placements before they become resource-cache keys. */
export function canonical(ref: Location.Ref) {
return Location.Ref.make({
+7 -13
View File
@@ -2,8 +2,8 @@ import { Context, Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Optio
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Instance } from "./instance.js"
import { Location } from "./location.js"
import { LocationLifecycle } from "./location-lifecycle.js"
import { LocationServiceMap } from "./location-service-map.js"
import { Rpc } from "./rpc.js"
export { LocationServiceMap } from "./location-service-map.js"
@@ -28,17 +28,12 @@ export function buildLocationServiceMap(
).pipe(
Effect.onExit((exit) => {
const finish = Effect.suspend(() => {
if (Exit.isSuccess(exit)) {
return Effect.gen(function* () {
const lifecycle = Context.get(exit.value, LocationLifecycle.Service)
// A boot detached while in flight still needs shutdown and cancellation.
if (Option.getOrUndefined(MutableHashMap.get(builds, ref)) !== build)
return yield* lifecycle.shutdown
build.close = lifecycle.shutdown
})
}
// An explicitly invalidated build must not evict its replacement.
if (Option.getOrUndefined(MutableHashMap.get(builds, ref)) !== build) return Effect.void
if (Exit.isSuccess(exit)) {
build.close = Context.get(exit.value, Rpc.Service).close
return Effect.void
}
MutableHashMap.remove(builds, ref)
// Evict once per failed build, before its result reaches borrowers.
return Exit.isFailure(exit) ? inner.invalidate(ref) : Effect.void
@@ -66,11 +61,10 @@ export function buildLocationServiceMap(
const key = LocationServiceMap.canonical(ref)
const build = Option.getOrUndefined(MutableHashMap.get(builds, key))
MutableHashMap.remove(builds, key)
// Detach routing first, then cancel interactions and notify clients. Running
// steps retain their borrowed graph until they can hand off at a boundary.
// Detach routing first, then end pending RPCs that still borrow the old graph.
// Do not await a boot here: failed/in-flight builds have their own cleanup path.
return inner.invalidate(key).pipe(Effect.andThen(build?.close ?? Effect.void))
}).pipe(Effect.uninterruptible),
}),
}
// Cached instances borrow their owner instead of retaining its Layer scope.
const bindings: LayerNode.Replacements = [
+12 -23
View File
@@ -101,7 +101,6 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
}
export interface Interface {
readonly close: Effect.Effect<void>
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
@@ -128,22 +127,18 @@ const layer = Layer.effect(
const saved = yield* PermissionSaved.Service
const hooks = yield* PluginHooks.Service
const pending = new Map<ID, Pending>()
let closed = false
const close = Effect.gen(function* () {
closed = true
yield* Effect.forEach(Array.from(pending.values()), (item) =>
bus
.publish(Permission.Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "reject",
})
.pipe(Effect.ensuring(Deferred.fail(item.deferred, new DeclinedError()))),
)
pending.clear()
}).pipe(Effect.uninterruptible)
yield* Effect.addFinalizer(() => close)
yield* Effect.addFinalizer(() =>
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), {
discard: true,
}).pipe(
Effect.ensuring(
Effect.sync(() => {
pending.clear()
}),
),
),
)
const savedRules = Effect.fnUntraced(function* () {
return (yield* saved.list({ projectID: location.project.id })).map(
@@ -206,10 +201,6 @@ const layer = Layer.effect(
Effect.gen(function* () {
const deferred = yield* Deferred.make<void, DeclinedError | CorrectedError>()
const item = { request, agent, deferred }
if (closed) {
yield* Deferred.fail(deferred, new DeclinedError())
return item
}
if (pending.has(request.id))
return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`))
pending.set(request.id, item)
@@ -221,7 +212,6 @@ const layer = Layer.effect(
)
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
if (closed) return { id: input.id ?? ID.create(), effect: "deny" as const }
const result = yield* evaluateInput(input)
const value = request(input, result.message)
if (result.effect === "ask") yield* create(value, input.agent)
@@ -230,7 +220,6 @@ const layer = Layer.effect(
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
Effect.gen(function* () {
if (closed) return yield* Effect.die(new DeclinedError())
const result = yield* evaluateInput(input)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
@@ -332,7 +321,7 @@ const layer = Layer.effect(
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})
return Service.of({ ask, assert, reply, get, forSession, list, close })
return Service.of({ ask, assert, reply, get, forSession, list })
}),
)
+9 -13
View File
@@ -42,17 +42,13 @@ that section.
CLI and TUI preferences are separate from OpenCode's server and project
configuration. They live in the global `~/.config/opencode/cli.json`, or
`$XDG_CONFIG_HOME/opencode/cli.json` when `XDG_CONFIG_HOME` is set. There is no
project-local CLI configuration. Set `OPENCODE_CLI_CONFIG_CONTENT` to merge
inline JSON over the global settings. Most preferences can also be changed from
the TUI by pressing `Ctrl+P` and selecting **Open settings**.
project-local CLI configuration. Most preferences can also be changed from the
TUI by pressing `Ctrl+P` and selecting **Open settings**.
### [Settings](https://opencode.ai/v2/docs/cli/config)
Fetch the full [CLI settings reference](https://opencode.ai/v2/docs/cli/config)
before editing `cli.json`. It documents every terminal-only setting, accepted
values, and examples, including themes, input, sessions, tabs, diffs, alerts,
Mini, keybindings, terminal plugins, and debugging. Do not put these settings
in `opencode.json(c)`.
Fetch the full [CLI configuration guide](https://opencode.ai/v2/docs/cli/config)
before editing `cli.json`. It covers terminal-only settings such as themes,
keybindings, terminal plugins, scrolling, attention alerts, diff presentation,
and terminal integration. Do not put these settings in `opencode.json(c)`.
### [Keybinds](https://opencode.ai/v2/docs/cli/keybinds)
@@ -96,7 +92,7 @@ Common configuration fields include `model`, `default_agent`, `permissions`,
`references`, `formatter`, and `lsp`.
This configuration is distinct from `cli.json`. Use the
[CLI settings reference](https://opencode.ai/v2/docs/cli/config) for terminal
[CLI configuration guide](https://opencode.ai/v2/docs/cli/config) for terminal
preferences, especially themes and keybindings.
Do not guess field names or shapes. Fetch the V2 configuration guide and its
@@ -194,7 +190,7 @@ HTTP method and path or an OpenAPI operation ID.
Call an endpoint with an HTTP method and path:
```sh
opencode api get /api/info
opencode api get /api/status
```
Pass a request body with `--data` or `-d`, and additional headers with
@@ -245,7 +241,7 @@ OpenCode runs a client and a background server. Start by determining whether a
problem belongs to the client, the shared server, or one project.
- Check the service with `opencode service status` and verify the API with
`opencode api get /api/info`.
`opencode api get /api/status`.
- Compare with `opencode --standalone`, which runs the TUI with a private
server, to isolate shared-service issues.
- Inspect `~/.local/share/opencode/log/opencode.log`. Filter `role=cli` for
-1
View File
@@ -108,7 +108,6 @@ export const layer = Layer.effect(
return yield* SessionRunner.DrainResult.$match(result, {
Complete: () => Effect.void,
Moved: (result) => drain(sessionID, false, result.continuation, promotable),
Reloaded: (result) => drain(sessionID, result.force, result.continuation, promotable),
})
})
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
@@ -22,7 +22,6 @@ export type Continuation = { readonly step: number }
export type DrainResult = Data.TaggedEnum<{
Complete: {}
Moved: { readonly continuation?: Continuation }
Reloaded: { readonly force: boolean; readonly continuation?: Continuation }
}>
export const DrainResult = Data.taggedEnum<DrainResult>()
-8
View File
@@ -5,7 +5,6 @@ import { and, desc, eq, sql } from "drizzle-orm"
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { LocationLifecycle } from "../../location-lifecycle.js"
import { InstructionState } from "../instruction-state.js"
import { SessionCompaction } from "../compaction.js"
import { SessionContext } from "../context.js"
@@ -38,7 +37,6 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const lifecycle = yield* LocationLifecycle.Service
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
const modelTransport = yield* SessionModelTransport.Service
@@ -71,10 +69,6 @@ const layer = Layer.effect(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
while (true) {
if (lifecycle.isClosed()) {
yield* restore(modelTransport.close(sessionID))
return DrainResult.Reloaded({ force, continuation: continuing ? { step } : undefined })
}
// Location entry and idle boundaries allow queued controls, not necessarily queued prompts.
const pending = yield* SessionInbox.serialized(
sessionID,
@@ -246,7 +240,6 @@ const layer = Layer.effect(
webSocket: "session",
})
const outcome = yield* steps.attempt({
isLocationClosed: lifecycle.isClosed,
sessionID,
assistantMessageID,
agent: loaded.agent.id,
@@ -363,7 +356,6 @@ export const node = makeLocationNode({
layer,
deps: [
Bus.node,
LocationLifecycle.node,
llmClient,
SessionContext.node,
SessionModelTransport.node,
+1 -7
View File
@@ -74,13 +74,7 @@ const retryAfter = (input: Input) => {
return undefined
}
// Exponential from 2s capped at 10s per gap, for 10 retries: 2, 4, 8, then 10 × 7, about 84s of
// waiting when every attempt fails (67101s with jitter). `min` takes the faster schedule, so the
// cap applies per gap; `max` with `recurs` bounds the count.
const schedule = Schedule.max([
Schedule.min([Schedule.exponential("2 seconds"), Schedule.spaced("10 seconds")]),
Schedule.recurs(10),
]).pipe(
const schedule = Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.jittered,
Schedule.setInputType<Input>(),
Schedule.modifyDelay(({ input, duration: delay }) => {
+3 -8
View File
@@ -42,7 +42,6 @@ export type Outcome = Data.TaggedEnum<{
export const Outcome = Data.taggedEnum<Outcome>()
interface Input {
readonly isLocationClosed: () => boolean
readonly sessionID: SessionSchema.ID
readonly assistantMessageID: SessionMessage.ID
readonly agent: Agent.ID
@@ -191,9 +190,8 @@ export const make = Effect.gen(function* () {
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message: input.isLocationClosed()
? "Interaction cancelled because the location shut down"
: decline.reason._tag === "QuestionTool.CancelledError"
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
@@ -253,10 +251,7 @@ export const make = Effect.gen(function* () {
return Outcome.Continue({ error: llmError, decision: retry })
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) {
if (input.isLocationClosed()) return Outcome.Completed({ needsContinuation: true })
return yield* Effect.interrupt
}
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
+2 -2
View File
@@ -804,12 +804,12 @@ it.effect("classifies retryable AI SDK failures with retry-after details", () =>
it.effect("classifies data-only AI SDK provider codes", () =>
Effect.gen(function* () {
const data = {
error: { code: "rate_limit_error", metadata: { requestId: "data-request", retryable: true } },
error: { code: "api_error", metadata: { requestId: "data-request", retryable: true } },
trace: { region: "test-region" },
}
const cause = apiCallError({ statusCode: 400, data })
const error = yield* streamFailure(cause)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason.http?.status).toBe(400)
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
expect(error.reason.body).toBe(JSON.stringify(data))
+2 -2
View File
@@ -1,5 +1,5 @@
import { Permission } from "@opencode/core/permission"
import { Effect, Layer } from "effect"
import { Layer } from "effect"
export const permissionLayer = (overrides: Partial<Permission.Interface> = {}) =>
Layer.mock(Permission.Service, { close: Effect.void, ...overrides })
Layer.mock(Permission.Service, overrides)
-1
View File
@@ -55,7 +55,6 @@ const generateLayer = Layer.succeed(Generate.Service, Generate.Service.of({ text
const permissionLayer = Layer.succeed(
Permission.Service,
Permission.Service.of({
close: Effect.void,
ask: (input) => Effect.succeed({ id: input.id ?? Permission.ID.create(), effect: "ask" }),
assert: () => Effect.void,
reply: () => Effect.void,
+33 -43
View File
@@ -580,12 +580,6 @@ const scenario = (
}),
)
// Nominal retry gaps: exponential from 2s capped at 10s, for 10 retries.
const RETRY_GAPS = [2_000, 4_000, 8_000, ...Array<number>(7).fill(10_000)]
// Longest possible gap per retry (+20% jitter); advancing the clock by these always fires the retry.
const RETRY_GAPS_MAX = RETRY_GAPS.map((gap) => gap * 1.2)
const RETRY_ATTEMPTS = RETRY_GAPS.map((_, index) => index + 2)
// Subscribe before resuming; model requests can arrive before retry backoff is scheduled.
const subscribeRetries = (s: Scenario) =>
Effect.gen(function* () {
@@ -2682,8 +2676,8 @@ describe("SessionRunnerLLM", () => {
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
expect(attempts).toEqual(RETRY_ATTEMPTS)
expect(s.requests).toHaveLength(RETRY_ATTEMPTS.length + 2)
expect(attempts).toEqual([2, 3, 4, 5])
expect(s.requests).toHaveLength(6)
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
status: "failed",
error: { type: "provider.transport", message: "Provider unavailable" },
@@ -5482,34 +5476,28 @@ describe("SessionRunnerLLM", () => {
LLMEvent.textStart({ id: "mixed-partial" }),
LLMEvent.textDelta({ id: "mixed-partial", text: "Partial" }),
)
// Alternate transparent failures and partial continuations until the retry allowance is spent.
const outcomes = RETRY_GAPS.map((_, index) => (index % 2 === 0 ? Stream.fail(failure) : partial))
yield* s.llm.push(...outcomes, partial)
yield* s.llm.push(Stream.fail(failure), partial, Stream.fail(failure), partial, partial)
const run = yield* s.resume.pipe(Effect.forkChild)
const identities: SessionMessage.ID[] = []
for (const delay of RETRY_GAPS_MAX) {
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
identities.push(yield* Queue.take(scheduled))
yield* TestClock.adjust(delay)
}
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(s.requests).toHaveLength(RETRY_GAPS.length + 1)
// A transparent retry keeps the assistant identity; a partial continuation starts a new one.
for (const [index, identity] of identities.entries()) {
if (index === 0) continue
if (index % 2 === 1) expect(identity).toBe(identities[index - 1])
else expect(identity).not.toBe(identities[index - 1])
}
const partials = outcomes.filter((outcome) => outcome === partial).length
expect(s.requests).toHaveLength(5)
expect(identities[0]).toBe(identities[1])
expect(identities[2]).toBe(identities[3])
expect(identities[0]).not.toBe(identities[2])
const messages = yield* s.context
expect(messages.filter((message) => message.type === "assistant")).toHaveLength(partials + 1)
expect(messages.filter((message) => message.type === "synthetic")).toHaveLength(partials)
expect(messages.filter((message) => message.type === "assistant")).toHaveLength(3)
expect(messages.filter((message) => message.type === "synthetic")).toHaveLength(2)
const events = yield* recordedEventTypes(sessionID)
expect(events.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(RETRY_GAPS.length)
expect(events.filter((type) => type === "session.step.failed.1")).toHaveLength(partials + 1)
expect(events.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(4)
expect(events.filter((type) => type === "session.step.failed.1")).toHaveLength(3)
},
)
scenario("stops incomplete stream continuations once the retry allowance is spent", function* (s) {
scenario("stops incomplete stream continuations after five total attempts", function* (s) {
yield* s.admit("Exhaust partial continuations")
const failure = incompleteStream()
yield* s.llm.always(
@@ -5523,30 +5511,30 @@ describe("SessionRunnerLLM", () => {
const scheduled = yield* subscribeRetries(s)
const run = yield* s.resume.pipe(Effect.forkChild)
for (const delay of RETRY_GAPS_MAX) {
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
yield* Queue.take(scheduled)
yield* TestClock.adjust(delay)
}
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(s.requests).toHaveLength(RETRY_GAPS.length + 1)
expect(s.requests).toHaveLength(5)
const context = yield* s.context
expect(context.filter((message) => message.type === "assistant")).toHaveLength(RETRY_GAPS.length + 1)
expect(context.filter((message) => message.type === "synthetic")).toHaveLength(RETRY_GAPS.length)
expect(context.filter((message) => message.type === "assistant")).toHaveLength(5)
expect(context.filter((message) => message.type === "synthetic")).toHaveLength(4)
})
scenario("stops once the retry allowance is spent", function* (s) {
scenario("stops after five total retry attempts", function* (s) {
yield* s.admit("Exhaust retries")
const failure = providerUnavailable()
yield* s.llm.always(Stream.fail(failure))
const scheduled = yield* subscribeRetries(s)
const run = yield* s.resume.pipe(Effect.forkChild)
for (const delay of RETRY_GAPS_MAX) {
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
yield* Queue.take(scheduled)
yield* TestClock.adjust(delay)
}
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(s.requests).toHaveLength(RETRY_GAPS.length + 1)
expect(s.requests).toHaveLength(5)
const retries = yield* s.db
.select({ data: EventTable.data })
@@ -5555,20 +5543,22 @@ describe("SessionRunnerLLM", () => {
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
// Each scheduled time falls within the jittered cumulative window for that retry.
expect(retries).toHaveLength(RETRY_GAPS.length)
let elapsed = 0
for (const [index, gap] of RETRY_GAPS.entries()) {
elapsed += gap
expect(retries[index]?.data.at).toBeGreaterThanOrEqual(elapsed * 0.8)
expect(retries[index]?.data.at).toBeLessThanOrEqual(elapsed * 1.2)
for (const [index, range] of [
[1_600, 2_400],
[4_800, 7_200],
[11_200, 16_800],
[24_000, 36_000],
].entries()) {
expect(retries[index]?.data.at).toBeGreaterThanOrEqual(range[0]!)
expect(retries[index]?.data.at).toBeLessThanOrEqual(range[1]!)
}
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(
RETRY_GAPS.length + 1,
)
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
const assistant = requireAssistant(yield* s.context)
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
...RETRY_GAPS.map(() => ({ type: "session.step.started.1" })),
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
{ type: "session.step.failed.1" },
])
-1
View File
@@ -100,7 +100,6 @@ for (const fixture of [
)
const result = yield* steps
.attempt({
isLocationClosed: () => false,
sessionID,
assistantMessageID,
agent: Agent.defaultID,
@@ -15,31 +15,6 @@ test.each(["build", "serve"] as const)("configures minification for %s", async (
expect(result?.config.renderer?.build?.sourcemap).toBe(true)
})
test("onboarding preview is enabled only by the development test flag", async () => {
const previous = process.env.OPENCODE_TEST_ONBOARDING
try {
process.env.OPENCODE_TEST_ONBOARDING = "1"
for (const command of ["build", "serve"] as const) {
const result = await loadConfigFromFile(
{ command, mode: command === "build" ? "production" : "development" },
`${import.meta.dirname}/electron.vite.config.ts`,
)
expect(result?.config.renderer?.define?.["import.meta.env.OPENCODE_TEST_ONBOARDING"]).toBe(
JSON.stringify(command === "serve"),
)
}
delete process.env.OPENCODE_TEST_ONBOARDING
const result = await loadConfigFromFile(
{ command: "serve", mode: "development" },
`${import.meta.dirname}/electron.vite.config.ts`,
)
expect(result?.config.renderer?.define?.["import.meta.env.OPENCODE_TEST_ONBOARDING"]).toBe("false")
} finally {
delete process.env.OPENCODE_TEST_ONBOARDING
if (previous !== undefined) process.env.OPENCODE_TEST_ONBOARDING = previous
}
})
test("does not package external copies of bundled dependencies", () => {
for (const name of ["effect", "@effect/platform-node", "@effect/platform-node-shared", "drizzle-orm"]) {
expect(Object.keys(pkg.dependencies)).not.toContain(name)
-3
View File
@@ -96,9 +96,6 @@ const require = __cjs_mod__.createRequire(import.meta.url);
define: {
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
"import.meta.env.OPENCODE_TEST_ONBOARDING": JSON.stringify(
command === "serve" && process.env.OPENCODE_TEST_ONBOARDING === "1",
),
},
plugins: [pickerPlugin(), appPlugin, sentry],
publicDir: "../../../app/public",
@@ -42,10 +42,7 @@ export const configureApplication = Effect.fn("Application.configure")(function*
const testRoot = yield* createTestRoot()
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID))
if (testRoot) {
app.setPath("sessionData", path.join(testRoot, "session"))
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
}
if (testRoot) app.setPath("sessionData", path.join(testRoot, "session"))
})
export function acquireApplicationLock() {
@@ -102,7 +99,7 @@ const createTestRoot = Effect.fn("Application.createTestRoot")(function* () {
if (!root) return undefined
if (testOnboarding) yield* fs.remove(root, { recursive: true, force: true })
yield* Effect.forEach(
["data", "config", "cache", "state", "desktop", "session", "documents"],
["data", "config", "cache", "state", "desktop", "session"],
(dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }),
{ discard: true },
)
+1 -1
View File
@@ -6,7 +6,7 @@ export async function checkHealth(url: string, password?: string | null): Promis
}
try {
const res = await fetch(new URL("/api/info", url), {
const res = await fetch(new URL("/api/status", url), {
method: "GET",
headers,
signal: AbortSignal.timeout(3000),
+1 -1
View File
@@ -354,7 +354,7 @@ const waitReady = Effect.fn("Ssh.waitReady")(function* (http: SshHttp, authentic
const checkHealth = Effect.fn("Ssh.checkHealth")(function* (http: SshHttp) {
const client = yield* HttpClient.HttpClient
return yield* client
.get(`${http.url}/api/info`, {
.get(`${http.url}/api/status`, {
headers: { authorization: `Basic ${Buffer.from(`opencode:${http.password}`).toString("base64")}` },
})
.pipe(
@@ -120,10 +120,6 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return
revealed = true
win.show()
if (!app.isPackaged && process.env.OPENCODE_TEST_ONBOARDING === "1") {
if (process.platform === "darwin") app.focus({ steal: true })
win.focus()
}
runFork(Effect.logInfo("main window visible", { window: id }))
}
const ready = () => {
+46 -61
View File
@@ -23,7 +23,7 @@ import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "
import { createStore } from "solid-js/store"
import type { ElectronAPI } from "./api-types"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { createDesktopPlatform } from "./platform"
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
import { bindDesktopMenu } from "./platform/menu"
import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization"
import { preloadStoredLocale } from "./startup/locale"
@@ -37,41 +37,53 @@ const MigrationStatus = lazy(() => import("./migration-status").then((module) =>
export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform; version: string }) {
const windowState = { id: props.api.getWindowID(), version: props.version }
const initialUrl = getLastActiveUrl(windowState.id)
const url = new URL(initialUrl, "http://localhost")
const url = new URL(getLastActiveUrl(windowState.id), "http://localhost")
const route = currentRoute(url.pathname, url.search)
const [startup, setStartup] = createStore({
const [startup, setStartup] = createStore<{ ready: boolean; visible: boolean; route: LayoutRoute }>({
ready: false,
visible: true,
themeReady: false,
onboardingReady: false,
drawingReady: false,
route,
})
const [firstLaunch] = createResource(() =>
props.api.isFirstLaunchOnboardingPending().catch((error) => {
console.error("[desktop-onboarding] first launch check failed", error)
return false
}),
return (
<>
<DesktopWindow
api={props.api}
updater={props.updater}
windowState={windowState}
onReady={() => setStartup("ready", true)}
onRoute={(route) => setStartup("route", route)}
/>
<Show when={startup.visible}>
<div
class="fixed inset-0 z-[100] transition-opacity duration-300 ease-out"
classList={{ "pointer-events-none opacity-0": startup.ready }}
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget || !startup.ready) return
setStartup("visible", false)
}}
>
<LoadingSplash deep={startup.route.type === "draft"} />
</div>
</Show>
</>
)
const platform = createDesktopPlatform(props.api, windowState, props.updater)
}
function DesktopWindow(props: {
api: ElectronAPI
updater: UpdaterPlatform
windowState: DesktopWindowState
onReady: () => void
onRoute: (route: LayoutRoute) => void
}) {
const platform = createDesktopPlatform(props.api, props.windowState, props.updater)
const [sidecar, { mutate: setSidecar }] = createResource(() => props.api.awaitInitialization())
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(() => preloadStoredLocale(platform))
const [initialRoute] = createResource(
() => !firstLaunch.loading && (firstLaunch() && initialUrl === "/" ? "/new-session" : initialUrl),
preloadRoute,
const [initialRoute] = createResource(() => preloadRoute(getLastActiveUrl(props.windowState.id)))
const router = (routerProps: BaseRouterProps) => (
<DesktopMemoryRouter {...routerProps} windowID={props.windowState.id} />
)
const router = (routerProps: BaseRouterProps) => <DesktopMemoryRouter {...routerProps} windowID={windowState.id} />
const readyToReveal = () =>
startup.ready &&
(!import.meta.env.OPENCODE_TEST_ONBOARDING || !firstLaunch() || initialUrl !== "/" || startup.drawingReady)
// Reveal only after the theme and the first-launch splash choice are both resolved.
createEffect(() => {
if (!startup.themeReady || firstLaunch.loading) return
void props.api.themeReady()
})
function ReadyApp() {
const wslServers = useWslServers()
@@ -79,13 +91,7 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
const sshConnections = createSshConnections(props.api.sshServers)
const language = useLanguage()
const ready = createMemo(
() =>
!firstLaunch.loading &&
!defaultServer.loading &&
!sidecar.loading &&
!locale.loading &&
!wslServers.isLoading &&
!ssh.loading,
() => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading && !ssh.loading,
)
const servers = createMemo(() => {
const data = initializationData(sidecar)
@@ -113,16 +119,14 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
{(key) => (
<AppInterface defaultServer={key} servers={servers()} router={router}>
<DesktopStartupReady
routeReady={!initialRoute.loading && startup.onboardingReady}
onReady={() => setStartup("ready", true)}
onRoute={(route) => setStartup("route", route)}
routeReady={() => !initialRoute.loading}
onReady={props.onReady}
onRoute={props.onRoute}
/>
<DesktopFirstLaunchOnboarding
api={props.api}
initialUrl={initialUrl}
initialUrl={getLastActiveUrl(props.windowState.id)}
serverKey={key}
pending={firstLaunch() ?? false}
onReady={() => setStartup("onboardingReady", true)}
/>
<DesktopEffects api={props.api} />
<Suspense fallback={null}>
@@ -144,36 +148,17 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
onNativeTranslations={(bundle) => void props.api.setNativeTranslations(bundle).catch(() => undefined)}
onThemeApplied={(mode, scheme) => {
void props.api.setTitlebar({ mode, scheme })
setStartup("themeReady", true)
void props.api.themeReady()
}}
>
<Show when={true}>{(_) => <ReadyApp />}</Show>
<Show when={!firstLaunch.loading && startup.visible}>
<div
data-component="startup-overlay"
class="fixed inset-0 z-[100] transition-opacity duration-300 ease-out"
classList={{ "pointer-events-none opacity-0": readyToReveal() }}
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget || !readyToReveal()) return
setStartup("visible", false)
}}
>
<LoadingSplash
firstLaunch={!!firstLaunch() && initialUrl === "/"}
deep={startup.route.type === "draft"}
platform={platform}
preview={import.meta.env.OPENCODE_TEST_ONBOARDING}
onDrawEnd={() => setStartup("drawingReady", true)}
/>
</div>
</Show>
</AppBaseProviders>
</PlatformProvider>
)
}
function DesktopStartupReady(props: {
routeReady: boolean
routeReady: () => boolean
onReady: () => void
onRoute: (route: LayoutRoute) => void
}) {
@@ -181,7 +166,7 @@ function DesktopStartupReady(props: {
const route = useCurrentRoute()
createEffect(() => props.onRoute(route()))
createEffect(() => {
if (!props.routeReady || !tabs.ready() || !tabs.infoReady()) return
if (!props.routeReady() || !tabs.ready() || !tabs.infoReady()) return
props.onReady()
})
return null
-4
View File
@@ -1,10 +1,6 @@
import type { ElectronNative } from "../preload/types"
declare global {
interface ImportMetaEnv {
readonly OPENCODE_TEST_ONBOARDING: boolean
}
interface Window {
electron: ElectronNative
__OPENCODE__?: {
@@ -1,38 +1,31 @@
import { ServerConnection, useCurrentRoute, useGlobal, useServers, useTabs } from "@opencode/app/desktop"
import { createResource } from "solid-js"
import { ServerConnection, useServers, useTabs } from "@opencode/app/desktop"
import { onMount } from "solid-js"
import type { ElectronAPI } from "../api-types"
export function DesktopFirstLaunchOnboarding(props: {
api: ElectronAPI
serverKey: ServerConnection.Key
initialUrl: string
pending: boolean
onReady: () => void
}) {
const server = useServers()
const global = useGlobal()
const tabs = useTabs()
const route = useCurrentRoute()
const [completed] = createResource(async () => {
await runFirstLaunchOnboarding()
return null
onMount(() => {
void runFirstLaunchOnboarding()
})
async function runFirstLaunchOnboarding() {
try {
if (!props.pending) return
const pending = await props.api.isFirstLaunchOnboardingPending()
if (!pending) return
await Promise.all([tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()))
const shouldTrigger =
props.initialUrl === "/" &&
route().type === "home" &&
tabs.store.length === 0 &&
server.list.every(ServerConnection.builtin)
props.initialUrl === "/" && tabs.store.length === 0 && server.list.every(ServerConnection.builtin)
console.info("[desktop-onboarding] first launch onboarding evaluated", {
pending: props.pending,
pending,
shouldTrigger,
initialUrl: props.initialUrl,
tabs: tabs.store.length,
@@ -46,18 +39,11 @@ export function DesktopFirstLaunchOnboarding(props: {
const projects = server.projects.forServer(props.serverKey)
projects.open(directory)
projects.touch(directory)
const connection = server.list.find((connection) => ServerConnection.key(connection) === props.serverKey)
if (connection) {
const data = global.ensureServerCtx(connection).data
// Load the initial provider/model state before the draft transition exposes the composer.
await Promise.all([data.location.provider.sync({ directory }), data.location.model.sync({ directory })])
}
tabs.select(await tabs.newDraft({ server: props.serverKey, directory }))
} finally {
props.onReady()
} catch (error) {
console.error("[desktop-onboarding] first launch onboarding failed", error)
}
}
// Let startup failures reach the app's recovery screen, including its splash boundary.
return <>{completed()}</>
return null
}
@@ -1,25 +0,0 @@
[data-component="first-launch-splash"] path {
/* Outline paths use a normalized length so the drawing scales with the wordmark. */
stroke-dasharray: 1;
animation: first-launch-wordmark-draw 1000ms ease-out both;
}
[data-component="first-launch-splash"][data-drawing="pending"] path {
animation-play-state: paused;
}
@keyframes first-launch-wordmark-draw {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="first-launch-splash"] path {
animation: none;
stroke-dasharray: none;
}
}
@@ -1,87 +1,15 @@
import { Splash } from "@opencode/ui/logo"
import { Wordmark } from "@opencode/ui/wordmark"
import type { Platform } from "@opencode/app/desktop"
import { onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import "./splash.css"
export function LoadingSplash(props: {
deep: boolean
firstLaunch: boolean
platform: Platform
preview: boolean
onDrawEnd: () => void
}) {
const [drawing, setDrawing] = createStore({ started: false })
onMount(() => {
if (!props.preview || !props.firstLaunch) return
const motion = window.matchMedia("(prefers-reduced-motion: reduce)")
const start = () => {
if (document.visibilityState !== "visible" || !document.hasFocus()) return
setDrawing("started", true)
if (motion.matches) props.onDrawEnd()
}
window.addEventListener("focus", start)
document.addEventListener("visibilitychange", start)
motion.addEventListener("change", start)
start()
onCleanup(() => {
window.removeEventListener("focus", start)
document.removeEventListener("visibilitychange", start)
motion.removeEventListener("change", start)
})
})
const titlebarHeight = () => {
const zoom = props.platform.webviewZoom?.() ?? 1
if (props.platform.os === "macos") return `max(36px, ${36 / zoom}px)`
if (props.platform.os === "windows")
return `max(36px, env(titlebar-area-height, ${44 / Math.min(Math.max(zoom, 0.25), 1)}px))`
return "36px"
}
export function LoadingSplash(props: { deep: boolean }) {
return (
<Show
when={props.firstLaunch}
fallback={
<div
data-component="startup-splash"
class="h-dvh w-screen flex flex-col items-center justify-center"
classList={{
"bg-v2-background-bg-deep": props.deep,
"bg-v2-background-bg-base": !props.deep,
}}
>
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
<div
class="h-dvh w-screen flex flex-col items-center justify-center"
classList={{
"bg-v2-background-bg-deep": props.deep,
"bg-v2-background-bg-base": !props.deep,
}}
>
<div
data-component="first-launch-splash"
data-drawing={props.preview && !drawing.started ? "pending" : "running"}
aria-hidden="true"
class="h-dvh w-screen bg-v2-background-bg-base"
style={{
// Match the default session's titlebar, panel insets, and wordmark position.
"padding-block-start": `calc(${titlebarHeight()} + ${props.platform.os === "windows" ? 1 : 8}px)`,
"padding-block-end": "8px",
"padding-inline": "32px",
}}
onAnimationEnd={(event) => {
if (event.animationName === "first-launch-wordmark-draw") props.onDrawEnd()
}}
>
<div class="relative size-full">
<div class="absolute inset-x-0 top-[25.375%]">
<Wordmark
outline
fade={false}
muted={false}
class="mx-auto block h-auto w-full max-w-[720px] text-v2-icon-icon-faint opacity-50"
/>
</div>
</div>
</div>
</Show>
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
)
}
+8 -63
View File
@@ -6,19 +6,19 @@
"description": "Experimental HttpApi surface for selected instance routes."
},
"paths": {
"/api/info": {
"/api/status": {
"get": {
"tags": ["server"],
"operationId": "server.info",
"operationId": "server.status",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "ServerInfo",
"description": "ServerStatus",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerInfo"
"$ref": "#/components/schemas/ServerStatus"
}
}
}
@@ -44,8 +44,8 @@
}
}
},
"description": "Return the server identity, connection URLs, paths, and readiness status.",
"summary": "Get server info"
"description": "Return the server identity, connection URLs, and readiness status.",
"summary": "Get server status"
}
},
"/api/location": {
@@ -121,51 +121,6 @@
"summary": "Get location"
}
},
"/api/location/reload": {
"post": {
"tags": ["location"],
"operationId": "location.reload",
"parameters": [],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Shut down and rebuild every loaded location. Pending permissions and forms are cancelled; running sessions continue with fresh services at the next step boundary. Emits location.shutdown for client recovery and responds once all replacement builds settle.",
"summary": "Reload locations"
}
},
"/api/agent": {
"get": {
"tags": ["agent"],
@@ -16599,7 +16554,7 @@
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"ServerInfo": {
"ServerStatus": {
"type": "object",
"properties": {
"version": {
@@ -16614,19 +16569,9 @@
"items": {
"type": "string"
}
},
"paths": {
"type": "object",
"properties": {
"tmp": {
"type": "string"
}
},
"required": ["tmp"],
"additionalProperties": false
}
},
"required": ["version", "pid", "urls", "paths"],
"required": ["version", "pid", "urls"],
"additionalProperties": false
},
"ServiceUnavailableErrorEncoded": {
+6 -8
View File
@@ -23,7 +23,7 @@ import { PersistentPtyGroup } from "./groups/persistent-pty.js"
import { ShellGroup } from "./groups/shell.js"
import { ReferenceGroup } from "./groups/reference.js"
import { Authorization } from "./middleware/authorization.js"
import { makeLocationGroup } from "./groups/location.js"
import { LocationGroup } from "./groups/location.js"
import { IntegrationGroup } from "./groups/integration.js"
import { WebSearchGroup } from "./groups/websearch.js"
import { McpGroup } from "./groups/mcp.js"
@@ -35,6 +35,7 @@ import { MigrationGroup } from "./groups/migration.js"
import { ConfigGroup } from "./groups/config.js"
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof AgentGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof PluginGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ModelGroup, LocationId>
@@ -59,17 +60,15 @@ type SessionGroups<
FormLocationId extends HttpApiMiddleware.AnyId,
FormLocationService,
> =
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>>
| ReturnType<
typeof makeSessionGroup<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>
>
| typeof MessageGroup
type FormGroups<LocationId extends HttpApiMiddleware.AnyId, LocationService> = ReturnType<
typeof makeFormGroup<LocationId, LocationService>
>
type LocationGroup<LocationId extends HttpApiMiddleware.AnyId, LocationService> = ReturnType<
typeof makeLocationGroup<LocationId, LocationService>
>
type MixedMiddlewareGroups<
LocationId extends HttpApiMiddleware.AnyId,
LocationService,
@@ -94,7 +93,6 @@ type ApiGroups<
| typeof PersistentPtyGroup
| typeof CredentialGroup
| LocationGroups<LocationId>
| LocationGroup<LocationId, LocationService>
| FormGroups<LocationId, LocationService>
| SessionGroups<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>
| MixedMiddlewareGroups<LocationId, LocationService, SessionLocationId, SessionLocationService>
@@ -154,7 +152,7 @@ const makeApiFromGroup = <
> =>
HttpApi.make("server")
.add(ServerGroup)
.add(makeLocationGroup(locationMiddleware))
.add(LocationGroup.middleware(locationMiddleware))
.add(AgentGroup.middleware(locationMiddleware))
.add(PluginGroup.middleware(locationMiddleware))
.add(makeSessionGroup(sessionLocationMiddleware, formLocationMiddleware))
-16
View File
@@ -65,22 +65,6 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
}),
),
)
.add(
HttpApiEndpoint.post("fs.write", "/api/experimental/fs/write", {
query: LocationQuery,
payload: FileSystem.WriteInput,
success: Location.response(FileSystem.Write),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "experimental.fs.write",
summary: "Write file",
description:
"Write base64 content to an absolute path or a path relative to the requested location, creating parent directories, and return the resolved absolute path. Unlike read, the target is not confined to the location. Experimental: may change without compatibility guarantees.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "filesystem",
+15 -35
View File
@@ -1,7 +1,6 @@
import { Location } from "@opencode/schema/location"
import { Context, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { ServiceUnavailableError } from "../errors.js"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const LocationQuery = Schema.Struct({
location: Schema.optional(
@@ -26,38 +25,19 @@ export const locationQueryOpenApi = OpenApi.annotations({
},
})
// Middleware is applied per endpoint: reload acts on every loaded location and
// must not boot the caller's location first.
export const makeLocationGroup = <LocationId extends HttpApiMiddleware.AnyId, LocationService>(
locationMiddleware: Context.Key<LocationId, LocationService>,
) =>
HttpApiGroup.make("server.location")
.add(
HttpApiEndpoint.get("location.get", "/api/location", {
query: LocationQuery,
success: Location.PublicInfo,
})
.middleware(locationMiddleware)
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "location.get",
summary: "Get location",
description: "Resolve the requested location or the server default location.",
}),
),
)
.add(
HttpApiEndpoint.post("location.reload", "/api/location/reload", {
success: HttpApiSchema.NoContent,
error: ServiceUnavailableError,
}).annotateMerge(
export const LocationGroup = HttpApiGroup.make("server.location")
.add(
HttpApiEndpoint.get("location.get", "/api/location", {
query: LocationQuery,
success: Location.PublicInfo,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "location.reload",
summary: "Reload locations",
description:
"Shut down and rebuild every loaded location. Pending permissions and forms are cancelled; running sessions continue with fresh services at the next step boundary. Emits location.shutdown for client recovery and responds once all replacement builds settle.",
identifier: "location.get",
summary: "Get location",
description: "Resolve the requested location or the server default location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "location" }))
)
.annotateMerge(OpenApi.annotations({ title: "location" }))
+8 -11
View File
@@ -1,26 +1,23 @@
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const ServerInfo = Schema.Struct({
export const ServerStatus = Schema.Struct({
version: Schema.String,
// 0 means the runtime has no OS process identity (e.g. workerd).
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
urls: Schema.Array(Schema.String),
paths: Schema.Struct({
tmp: Schema.String,
}),
}).annotate({ identifier: "ServerInfo" })
export type ServerInfo = typeof ServerInfo.Type
}).annotate({ identifier: "ServerStatus" })
export type ServerStatus = typeof ServerStatus.Type
export const ServerGroup = HttpApiGroup.make("server.server")
.add(
HttpApiEndpoint.get("server.info", "/api/info", {
success: ServerInfo,
HttpApiEndpoint.get("server.status", "/api/status", {
success: ServerStatus,
}).annotateMerge(
OpenApi.annotations({
identifier: "server.info",
summary: "Get server info",
description: "Return the server identity, connection URLs, paths, and readiness status.",
identifier: "server.status",
summary: "Get server status",
description: "Return the server identity, connection URLs, and readiness status.",
}),
),
)
-2
View File
@@ -14,7 +14,6 @@ import { InstallationEvent } from "./installation-event.js"
import { Integration } from "./integration.js"
import { LegacyEventV1 } from "./legacy-event.js"
import { LspEvent } from "./lsp-event.js"
import { LocationEvent } from "./location-event.js"
import { McpEvent } from "./mcp-event.js"
import { Model } from "./model.js"
import { ModelsDev } from "./models-dev.js"
@@ -41,7 +40,6 @@ import { WebSearch } from "./websearch.js"
const coreDefinitions = Event.inventory(...SessionEvent.Definitions)
const foundationDefinitions = Event.inventory(
...LocationEvent.Definitions,
...ModelsDev.Event.Definitions,
...Credential.Event.Definitions,
...Integration.Event.Definitions,
+1 -14
View File
@@ -3,8 +3,7 @@ export * as FileSystem from "./filesystem.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { ephemeral, inventory } from "./event.js"
import { Base64 } from "./prompt.js"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
const Changed = ephemeral({
type: "filesystem.changed",
@@ -42,15 +41,3 @@ export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
type: Schema.Literals(["file", "directory"]).pipe(optional),
limit: PositiveInt.pipe(optional),
}) {}
export class WriteInput extends Schema.Class<WriteInput>("FileSystem.WriteInput")({
path: Schema.String.annotate({
description: "An absolute path or a path relative to the requested location. Missing parent directories are created.",
}),
data: Base64,
}) {}
export interface Write extends Schema.Schema.Type<typeof Write> {}
export const Write = Schema.Struct({
path: AbsolutePath,
}).annotate({ identifier: "FileSystem.Write" })
-8
View File
@@ -1,8 +0,0 @@
export * as LocationEvent from "./location-event.js"
import { ephemeral, inventory } from "./event.js"
/** The location's cached services were shut down; clients must revalidate its reads. */
export const Shutdown = ephemeral({ type: "location.shutdown", schema: {} })
export const Definitions = inventory(Shutdown)

Some files were not shown because too many files have changed in this diff Show More