Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b1feb4c54 | |||
| ca2f54a68d | |||
| 0033c4474c | |||
| 80bcc8d478 | |||
| d0f6065e4c | |||
| b6da0a9cda | |||
| 967fa4d3bb | |||
| a35dc04d15 | |||
| 51bc4246d2 | |||
| a5e6263c58 | |||
| 966f5acef4 | |||
| 0b395cd13e | |||
| 35239bddef | |||
| 41367fa3ba | |||
| 97caa6bf82 | |||
| 191eb357bc | |||
| b03d064127 | |||
| 945ad08380 | |||
| cbc8847af8 | |||
| d58ebc5cf4 |
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# Compare SDK types generated from Hono vs HttpApi specs.
|
||||
# Sorts types alphabetically so only meaningful body differences show.
|
||||
#
|
||||
# Usage: ./scripts/diff-sdk-types.sh # full diff
|
||||
# ./scripts/diff-sdk-types.sh --stat # summary only
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SDK="$(cd "$DIR/../sdk/js" && pwd)"
|
||||
|
||||
normalize() {
|
||||
python3 -c "
|
||||
import re, sys
|
||||
content = open(sys.argv[1]).read()
|
||||
blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE)
|
||||
header, body = blocks[0], blocks[1:]
|
||||
body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '')
|
||||
sys.stdout.write(header + ''.join(body))
|
||||
" "$1"
|
||||
}
|
||||
|
||||
echo "Generating Hono SDK..." >&2
|
||||
(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1)
|
||||
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts
|
||||
git -C "$SDK" checkout -- src/ 2>/dev/null
|
||||
|
||||
echo "Generating HttpApi SDK..." >&2
|
||||
(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1)
|
||||
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts
|
||||
git -C "$SDK" checkout -- src/ 2>/dev/null
|
||||
|
||||
echo "" >&2
|
||||
if [[ "${1:-}" == "--stat" ]]; then
|
||||
diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true)
|
||||
honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true)
|
||||
aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true)
|
||||
total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ')
|
||||
echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total"
|
||||
echo ""
|
||||
if [[ $honly -gt 0 ]]; then
|
||||
echo "=== Hono-only types ==="
|
||||
printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /'
|
||||
echo ""
|
||||
fi
|
||||
if [[ $aonly -gt 0 ]]; then
|
||||
echo "=== HttpApi-only types ==="
|
||||
printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /'
|
||||
fi
|
||||
else
|
||||
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true
|
||||
fi
|
||||
@@ -129,6 +129,14 @@ Required before route deletion:
|
||||
- Compare generated SDK output against `dev` for every route group deletion.
|
||||
- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths.
|
||||
|
||||
V2 cleanup once SDK compatibility no longer needs the legacy Hono contract:
|
||||
|
||||
- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses.
|
||||
- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape.
|
||||
- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks.
|
||||
- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape.
|
||||
- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec.
|
||||
|
||||
### 5. Make HttpApi Default For JSON Routes
|
||||
|
||||
After JSON parity and SDK generation are covered:
|
||||
|
||||
@@ -31,8 +31,8 @@ export const Info = Schema.Struct({
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]),
|
||||
native: Schema.optional(Schema.Boolean),
|
||||
hidden: Schema.optional(Schema.Boolean),
|
||||
topP: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
topP: Schema.optional(Schema.Finite),
|
||||
temperature: Schema.optional(Schema.Finite),
|
||||
color: Schema.optional(Schema.String),
|
||||
permission: Permission.Ruleset,
|
||||
model: Schema.optional(
|
||||
@@ -44,7 +44,7 @@ export const Info = Schema.Struct({
|
||||
variant: Schema.optional(Schema.String),
|
||||
prompt: Schema.optional(Schema.String),
|
||||
options: Schema.Record(Schema.String, Schema.Unknown),
|
||||
steps: Schema.optional(Schema.Number),
|
||||
steps: Schema.optional(Schema.Finite),
|
||||
})
|
||||
.annotate({ identifier: "Agent" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
@@ -14,7 +15,7 @@ export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
||||
type: Schema.Literal("oauth"),
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: Schema.Number,
|
||||
expires: NonNegativeInt,
|
||||
accountId: Schema.optional(Schema.String),
|
||||
enterpriseUrl: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
@@ -34,4 +34,16 @@ export function payloads() {
|
||||
.toArray()
|
||||
}
|
||||
|
||||
export function effectPayloads() {
|
||||
return registry
|
||||
.entries()
|
||||
.map(([type, def]) =>
|
||||
Schema.Struct({
|
||||
type: Schema.Literal(type),
|
||||
properties: def.properties,
|
||||
}).annotate({ identifier: `Event.${type}` }),
|
||||
)
|
||||
.toArray()
|
||||
}
|
||||
|
||||
export * as BusEvent from "./bus-event"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { PositiveInt } from "@/util/schema"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
const DEFAULT_TOAST_DURATION = 5000
|
||||
@@ -38,7 +39,7 @@ export const TuiEvent = {
|
||||
title: Schema.optional(Schema.String),
|
||||
message: Schema.String,
|
||||
variant: Schema.Literals(["info", "success", "warning", "error"]),
|
||||
duration: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
|
||||
duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
|
||||
description: "Duration in milliseconds",
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -26,8 +26,8 @@ const AgentSchema = Schema.StructWithRest(
|
||||
variant: Schema.optional(Schema.String).annotate({
|
||||
description: "Default model variant for this agent (applies only when using the agent's configured model).",
|
||||
}),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Finite),
|
||||
top_p: Schema.optional(Schema.Finite),
|
||||
prompt: Schema.optional(Schema.String),
|
||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
|
||||
description: "@deprecated Use 'permission' field instead",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
|
||||
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
activeOrgName: Schema.optional(Schema.String),
|
||||
switchableOrgCount: Schema.Number,
|
||||
switchableOrgCount: NonNegativeInt,
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { PositiveInt, withStatics } from "@/util/schema"
|
||||
|
||||
export const Local = Schema.Struct({
|
||||
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
|
||||
@@ -13,7 +13,7 @@ export const Local = Schema.Struct({
|
||||
enabled: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Enable or disable the MCP server on startup",
|
||||
}),
|
||||
timeout: Schema.optional(Schema.Number).annotate({
|
||||
timeout: Schema.optional(PositiveInt).annotate({
|
||||
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
|
||||
}),
|
||||
})
|
||||
@@ -49,7 +49,7 @@ export const Remote = Schema.Struct({
|
||||
oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
|
||||
description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
|
||||
}),
|
||||
timeout: Schema.optional(Schema.Number).annotate({
|
||||
timeout: Schema.optional(PositiveInt).annotate({
|
||||
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -21,25 +21,25 @@ export const Model = Schema.Struct({
|
||||
),
|
||||
cost: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
cache_read: Schema.optional(Schema.Number),
|
||||
cache_write: Schema.optional(Schema.Number),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
context_over_200k: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
cache_read: Schema.optional(Schema.Number),
|
||||
cache_write: Schema.optional(Schema.Number),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
limit: Schema.optional(
|
||||
Schema.Struct({
|
||||
context: Schema.Number,
|
||||
input: Schema.optional(Schema.Number),
|
||||
output: Schema.Number,
|
||||
context: Schema.Finite,
|
||||
input: Schema.optional(Schema.Finite),
|
||||
output: Schema.Finite,
|
||||
}),
|
||||
),
|
||||
modalities: Schema.optional(
|
||||
|
||||
@@ -15,12 +15,12 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Protected } from "./protected"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { type DeepMutable, withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
path: Schema.String,
|
||||
added: Schema.Int,
|
||||
removed: Schema.Int,
|
||||
added: NonNegativeInt,
|
||||
removed: NonNegativeInt,
|
||||
status: Schema.Literals(["added", "deleted", "modified"]),
|
||||
})
|
||||
.annotate({ identifier: "File" })
|
||||
@@ -39,10 +39,10 @@ export const Node = Schema.Struct({
|
||||
export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
|
||||
|
||||
const Hunk = Schema.Struct({
|
||||
oldStart: Schema.Number,
|
||||
oldLines: Schema.Number,
|
||||
newStart: Schema.Number,
|
||||
newLines: Schema.Number,
|
||||
oldStart: NonNegativeInt,
|
||||
oldLines: NonNegativeInt,
|
||||
newStart: NonNegativeInt,
|
||||
newLines: NonNegativeInt,
|
||||
lines: Schema.Array(Schema.String),
|
||||
})
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
|
||||
import { which } from "@/util/which"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "ripgrep" })
|
||||
const VERSION = "15.1.0"
|
||||
@@ -27,19 +27,19 @@ const PLATFORM = {
|
||||
} as const
|
||||
|
||||
const TimeStats = Schema.Struct({
|
||||
secs: Schema.Number,
|
||||
nanos: Schema.Number,
|
||||
secs: NonNegativeInt,
|
||||
nanos: NonNegativeInt,
|
||||
human: Schema.String,
|
||||
})
|
||||
|
||||
const Stats = Schema.Struct({
|
||||
elapsed: TimeStats,
|
||||
searches: Schema.Number,
|
||||
searches_with_match: Schema.Number,
|
||||
bytes_searched: Schema.Number,
|
||||
bytes_printed: Schema.Number,
|
||||
matched_lines: Schema.Number,
|
||||
matches: Schema.Number,
|
||||
searches: NonNegativeInt,
|
||||
searches_with_match: NonNegativeInt,
|
||||
bytes_searched: NonNegativeInt,
|
||||
bytes_printed: NonNegativeInt,
|
||||
matched_lines: NonNegativeInt,
|
||||
matches: NonNegativeInt,
|
||||
})
|
||||
|
||||
const PathText = Schema.Struct({
|
||||
@@ -58,15 +58,15 @@ export const SearchMatch = Schema.Struct({
|
||||
lines: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
line_number: Schema.Number,
|
||||
absolute_offset: Schema.Number,
|
||||
line_number: NonNegativeInt,
|
||||
absolute_offset: NonNegativeInt,
|
||||
submatches: Schema.Array(
|
||||
Schema.Struct({
|
||||
match: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}),
|
||||
),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
@@ -80,7 +80,7 @@ const End = Schema.Struct({
|
||||
type: Schema.Literal("end"),
|
||||
data: Schema.Struct({
|
||||
path: PathText,
|
||||
binary_offset: Schema.NullOr(Schema.Number),
|
||||
binary_offset: Schema.NullOr(NonNegativeInt),
|
||||
stats: Stats,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import { spawn as lspspawn } from "./launch"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
|
||||
const log = Log.create({ service: "lsp" })
|
||||
@@ -23,8 +23,8 @@ export const Event = {
|
||||
}
|
||||
|
||||
const Position = Schema.Struct({
|
||||
line: Schema.Number,
|
||||
character: Schema.Number,
|
||||
line: NonNegativeInt,
|
||||
character: NonNegativeInt,
|
||||
})
|
||||
|
||||
export const Range = Schema.Struct({
|
||||
@@ -37,7 +37,7 @@ export type Range = typeof Range.Type
|
||||
|
||||
export const Symbol = Schema.Struct({
|
||||
name: Schema.String,
|
||||
kind: Schema.Number,
|
||||
kind: NonNegativeInt,
|
||||
location: Schema.Struct({
|
||||
uri: Schema.String,
|
||||
range: Range,
|
||||
@@ -50,7 +50,7 @@ export type Symbol = typeof Symbol.Type
|
||||
export const DocumentSymbol = Schema.Struct({
|
||||
name: Schema.String,
|
||||
detail: Schema.optional(Schema.String),
|
||||
kind: Schema.Number,
|
||||
kind: NonNegativeInt,
|
||||
range: Range,
|
||||
selectionRange: Range,
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "project" })
|
||||
|
||||
@@ -35,9 +35,9 @@ const ProjectCommands = Schema.Struct({
|
||||
})
|
||||
|
||||
const ProjectTime = Schema.Struct({
|
||||
created: Schema.Number,
|
||||
updated: Schema.Number,
|
||||
initialized: Schema.optional(Schema.Number),
|
||||
created: NonNegativeInt,
|
||||
updated: NonNegativeInt,
|
||||
initialized: Schema.optional(NonNegativeInt),
|
||||
})
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
|
||||
@@ -9,7 +9,7 @@ import { FileWatcher } from "@/file/watcher"
|
||||
import { Git } from "@/git"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "vcs" })
|
||||
|
||||
@@ -125,8 +125,8 @@ export type Info = Schema.Schema.Type<typeof Info>
|
||||
export const FileDiff = Schema.Struct({
|
||||
file: Schema.String,
|
||||
patch: Schema.String,
|
||||
additions: Schema.Number,
|
||||
deletions: Schema.Number,
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
|
||||
})
|
||||
.annotate({ identifier: "VcsFileDiff" })
|
||||
|
||||
@@ -58,13 +58,13 @@ export class Authorization extends Schema.Class<Authorization>("ProviderAuthAuth
|
||||
}
|
||||
|
||||
export const AuthorizeInput = Schema.Struct({
|
||||
method: Schema.Number.annotate({ description: "Auth method index" }),
|
||||
method: Schema.Finite.annotate({ description: "Auth method index" }),
|
||||
inputs: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Prompt inputs" }),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type AuthorizeInput = Schema.Schema.Type<typeof AuthorizeInput>
|
||||
|
||||
export const CallbackInput = Schema.Struct({
|
||||
method: Schema.Number.annotate({ description: "Auth method index" }),
|
||||
method: Schema.Finite.annotate({ description: "Auth method index" }),
|
||||
code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
|
||||
|
||||
@@ -22,16 +22,16 @@ const filepath = path.join(
|
||||
const ttl = 5 * 60 * 1000
|
||||
|
||||
const Cost = Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
cache_read: Schema.optional(Schema.Number),
|
||||
cache_write: Schema.optional(Schema.Number),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
context_over_200k: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
cache_read: Schema.optional(Schema.Number),
|
||||
cache_write: Schema.optional(Schema.Number),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
}),
|
||||
),
|
||||
})
|
||||
@@ -55,9 +55,9 @@ export const Model = Schema.Struct({
|
||||
),
|
||||
cost: Schema.optional(Cost),
|
||||
limit: Schema.Struct({
|
||||
context: Schema.Number,
|
||||
input: Schema.optional(Schema.Number),
|
||||
output: Schema.Number,
|
||||
context: Schema.Finite,
|
||||
input: Schema.optional(Schema.Finite),
|
||||
output: Schema.Finite,
|
||||
}),
|
||||
modalities: Schema.optional(
|
||||
Schema.Struct({
|
||||
|
||||
@@ -848,27 +848,27 @@ const ProviderCapabilities = Schema.Struct({
|
||||
})
|
||||
|
||||
const ProviderCacheCost = Schema.Struct({
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
read: Schema.Finite,
|
||||
write: Schema.Finite,
|
||||
})
|
||||
|
||||
const ProviderCost = Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache: ProviderCacheCost,
|
||||
experimentalOver200K: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache: ProviderCacheCost,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const ProviderLimit = Schema.Struct({
|
||||
context: Schema.Number,
|
||||
input: Schema.optional(Schema.Number),
|
||||
output: Schema.Number,
|
||||
context: Schema.Finite,
|
||||
input: Schema.optional(Schema.Finite),
|
||||
output: Schema.Finite,
|
||||
})
|
||||
|
||||
export const Model = Schema.Struct({
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { PtyID } from "./schema"
|
||||
import { Effect, Layer, Context, Schema, Types } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "pty" })
|
||||
|
||||
@@ -62,7 +62,7 @@ export const Info = Schema.Struct({
|
||||
args: Schema.Array(Schema.String),
|
||||
cwd: Schema.String,
|
||||
status: Schema.Literals(["running", "exited"]),
|
||||
pid: Schema.Number,
|
||||
pid: PositiveInt,
|
||||
})
|
||||
.annotate({ identifier: "Pty" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
@@ -83,8 +83,8 @@ export const UpdateInput = Schema.Struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
size: Schema.optional(
|
||||
Schema.Struct({
|
||||
rows: Schema.Number,
|
||||
cols: Schema.Number,
|
||||
rows: PositiveInt,
|
||||
cols: PositiveInt,
|
||||
}),
|
||||
),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
@@ -94,7 +94,7 @@ export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInpu
|
||||
export const Event = {
|
||||
Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })),
|
||||
Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })),
|
||||
Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: Schema.Number })),
|
||||
Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: NonNegativeInt })),
|
||||
Deleted: BusEvent.define("pty.deleted", Schema.Struct({ id: PtyID })),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
|
||||
export type Backend = "effect-httpapi" | "hono"
|
||||
|
||||
export type Selection = {
|
||||
backend: Backend
|
||||
reason: "env" | "stable" | "explicit"
|
||||
}
|
||||
|
||||
export type Attributes = ReturnType<typeof attributes>
|
||||
|
||||
export function select(): Selection {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return { backend: "effect-httpapi", reason: "env" }
|
||||
return { backend: "hono", reason: "stable" }
|
||||
}
|
||||
|
||||
export function attributes(selection: Selection): Record<string, string> {
|
||||
return {
|
||||
"opencode.server.backend": selection.backend,
|
||||
"opencode.server.backend.reason": selection.reason,
|
||||
"opencode.installation.channel": InstallationChannel,
|
||||
"opencode.installation.version": InstallationVersion,
|
||||
}
|
||||
}
|
||||
|
||||
export function force(selection: Selection, backend: Backend): Selection {
|
||||
return {
|
||||
backend,
|
||||
reason: selection.backend === backend ? selection.reason : "explicit",
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { basicAuth } from "hono/basic-auth"
|
||||
import { cors } from "hono/cors"
|
||||
import { compress } from "hono/compress"
|
||||
import * as ServerBackend from "./backend"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -49,20 +50,20 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => {
|
||||
return basicAuth({ username, password })(c, next)
|
||||
}
|
||||
|
||||
export const LoggerMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
const skip = c.req.path === "/log"
|
||||
if (!skip) {
|
||||
log.info("request", {
|
||||
export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const skip = c.req.path === "/log"
|
||||
if (skip) return next()
|
||||
const attributes = {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
})
|
||||
...backendAttributes,
|
||||
}
|
||||
log.info("request", attributes)
|
||||
const timer = log.time("request", attributes)
|
||||
await next()
|
||||
timer.stop()
|
||||
}
|
||||
const timer = log.time("request", {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
})
|
||||
await next()
|
||||
if (!skip) timer.stop()
|
||||
}
|
||||
|
||||
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
|
||||
|
||||
@@ -33,7 +33,7 @@ function headers(req: Request, extra?: HeadersInit) {
|
||||
return out
|
||||
}
|
||||
|
||||
function protocols(req: Request) {
|
||||
export function websocketProtocols(req: Request) {
|
||||
const value = req.headers.get("sec-websocket-protocol")
|
||||
if (!value) return []
|
||||
return value
|
||||
@@ -42,7 +42,7 @@ function protocols(req: Request) {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function socket(url: string | URL) {
|
||||
export function websocketTargetURL(url: string | URL) {
|
||||
const next = new URL(url)
|
||||
if (next.protocol === "http:") next.protocol = "ws:"
|
||||
if (next.protocol === "https:") next.protocol = "wss:"
|
||||
@@ -69,7 +69,7 @@ const app = (upgrade: UpgradeWebSocket) =>
|
||||
ws.close(1011, "missing proxy target")
|
||||
return
|
||||
}
|
||||
remote = new WebSocket(url, protocols(c.req.raw))
|
||||
remote = new WebSocket(url, websocketProtocols(c.req.raw))
|
||||
remote.binaryType = "arraybuffer"
|
||||
remote.onopen = () => {
|
||||
for (const item of queue) remote?.send(item)
|
||||
@@ -150,7 +150,7 @@ export function websocket(
|
||||
proxy.pathname = "/__workspace_ws"
|
||||
proxy.search = ""
|
||||
const next = new Headers(req.headers)
|
||||
next.set("x-opencode-proxy-url", socket(target))
|
||||
next.set("x-opencode-proxy-url", websocketTargetURL(target))
|
||||
for (const [key, value] of new Headers(extra).entries()) {
|
||||
next.set(key, value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi } from "effect/unstable/httpapi"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { ConfigApi } from "./groups/config"
|
||||
import { ControlApi } from "./groups/control"
|
||||
import { EventApi } from "./event"
|
||||
import { ExperimentalApi } from "./groups/experimental"
|
||||
import { FileApi } from "./groups/file"
|
||||
import { GlobalApi } from "./groups/global"
|
||||
import { InstanceApi } from "./groups/instance"
|
||||
import { McpApi } from "./groups/mcp"
|
||||
import { PermissionApi } from "./groups/permission"
|
||||
import { ProjectApi } from "./groups/project"
|
||||
import { ProviderApi } from "./groups/provider"
|
||||
import { PtyApi, PtyConnectApi } from "./groups/pty"
|
||||
import { QuestionApi } from "./groups/question"
|
||||
import { SessionApi } from "./groups/session"
|
||||
import { SyncApi } from "./groups/sync"
|
||||
import { TuiApi } from "./groups/tui"
|
||||
import { WorkspaceApi } from "./groups/workspace"
|
||||
|
||||
// SSE event schemas built from the same BusEvent/SyncEvent registries that
|
||||
// the Hono spec uses, so both specs emit identical Event/SyncEvent components.
|
||||
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
|
||||
const SyncEventSchemas = SyncEvent.effectPayloads()
|
||||
|
||||
export const RootHttpApi = HttpApi.make("opencode-root").addHttpApi(ControlApi).addHttpApi(GlobalApi)
|
||||
|
||||
export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||
.addHttpApi(ConfigApi)
|
||||
.addHttpApi(ExperimentalApi)
|
||||
.addHttpApi(FileApi)
|
||||
.addHttpApi(InstanceApi)
|
||||
.addHttpApi(McpApi)
|
||||
.addHttpApi(ProjectApi)
|
||||
.addHttpApi(PtyApi)
|
||||
.addHttpApi(QuestionApi)
|
||||
.addHttpApi(PermissionApi)
|
||||
.addHttpApi(ProviderApi)
|
||||
.addHttpApi(SessionApi)
|
||||
.addHttpApi(SyncApi)
|
||||
.addHttpApi(TuiApi)
|
||||
.addHttpApi(WorkspaceApi)
|
||||
|
||||
export const OpenCodeHttpApi = HttpApi.make("opencode")
|
||||
.addHttpApi(RootHttpApi)
|
||||
.addHttpApi(EventApi)
|
||||
.addHttpApi(InstanceHttpApi)
|
||||
.addHttpApi(PtyConnectApi)
|
||||
.annotate(HttpApi.AdditionalSchemas, [EventSchema, ...SyncEventSchemas])
|
||||
|
||||
export type RootHttpApiType = typeof RootHttpApi
|
||||
export type InstanceHttpApiType = typeof InstanceHttpApi
|
||||
@@ -4,6 +4,7 @@ import { Effect, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -27,8 +28,13 @@ export const EventApi = HttpApi.make("event").add(
|
||||
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
|
||||
)
|
||||
|
||||
function eventData(data: unknown) {
|
||||
return `data: ${JSON.stringify(data)}\n\n`
|
||||
function eventData(data: unknown): Sse.Event {
|
||||
return {
|
||||
_tag: "Event",
|
||||
event: "message",
|
||||
id: undefined,
|
||||
data: JSON.stringify(data),
|
||||
}
|
||||
}
|
||||
|
||||
export const eventRoute = HttpRouter.add(
|
||||
@@ -47,6 +53,7 @@ export const eventRoute = HttpRouter.add(
|
||||
Stream.make({ type: "server.connected", properties: {} }).pipe(
|
||||
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
|
||||
Stream.map(eventData),
|
||||
Stream.pipeThroughChannel(Sse.encode()),
|
||||
Stream.encodeText,
|
||||
Stream.ensuring(Effect.sync(() => log.info("event disconnected"))),
|
||||
),
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
import { Config } from "@/config/config"
|
||||
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
|
||||
import { Installation } from "@/installation"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
const GlobalHealth = Schema.Struct({
|
||||
healthy: Schema.Literal(true),
|
||||
version: Schema.String,
|
||||
}).annotate({ identifier: "GlobalHealth" })
|
||||
|
||||
const GlobalEventSchema = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
project: Schema.optional(Schema.String),
|
||||
workspace: Schema.optional(Schema.String),
|
||||
payload: Schema.Unknown,
|
||||
}).annotate({ identifier: "GlobalEvent" })
|
||||
|
||||
const GlobalUpgradeInput = Schema.Struct({
|
||||
target: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "GlobalUpgradeInput" })
|
||||
|
||||
const GlobalUpgradeResult = Schema.Union([
|
||||
Schema.Struct({
|
||||
success: Schema.Literal(true),
|
||||
version: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
success: Schema.Literal(false),
|
||||
error: Schema.String,
|
||||
}),
|
||||
]).annotate({ identifier: "GlobalUpgradeResult" })
|
||||
|
||||
export const GlobalPaths = {
|
||||
health: "/global/health",
|
||||
event: "/global/event",
|
||||
config: "/global/config",
|
||||
dispose: "/global/dispose",
|
||||
upgrade: "/global/upgrade",
|
||||
} as const
|
||||
|
||||
export const GlobalApi = HttpApi.make("global").add(
|
||||
HttpApiGroup.make("global")
|
||||
.add(
|
||||
HttpApiEndpoint.get("health", GlobalPaths.health, {
|
||||
success: GlobalHealth,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.health",
|
||||
summary: "Get health",
|
||||
description: "Get health information about the OpenCode server.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("event", GlobalPaths.event, {
|
||||
success: GlobalEventSchema,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.event",
|
||||
summary: "Get global events",
|
||||
description: "Subscribe to global events from the OpenCode system using server-sent events.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("configGet", GlobalPaths.config, {
|
||||
success: Config.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.config.get",
|
||||
summary: "Get global configuration",
|
||||
description: "Retrieve the current global OpenCode configuration settings and preferences.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, {
|
||||
payload: Config.Info,
|
||||
success: Config.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.config.update",
|
||||
summary: "Update global configuration",
|
||||
description: "Update global OpenCode configuration settings and preferences.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("dispose", GlobalPaths.dispose, {
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.dispose",
|
||||
summary: "Dispose instance",
|
||||
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, {
|
||||
payload: GlobalUpgradeInput,
|
||||
success: GlobalUpgradeResult,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.upgrade",
|
||||
summary: "Upgrade opencode",
|
||||
description: "Upgrade opencode to the specified version or latest if not specified.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })),
|
||||
)
|
||||
|
||||
function eventData(data: unknown) {
|
||||
return `data: ${JSON.stringify(data)}\n\n`
|
||||
}
|
||||
|
||||
function parseBody(body: string) {
|
||||
try {
|
||||
return JSON.parse(body || "{}") as unknown
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function eventResponse() {
|
||||
const encoder = new TextEncoder()
|
||||
let heartbeat: ReturnType<typeof setInterval> | undefined
|
||||
let unsubscribe = () => {}
|
||||
let done = false
|
||||
|
||||
const cleanup = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
if (heartbeat) clearInterval(heartbeat)
|
||||
unsubscribe()
|
||||
log.info("global event disconnected")
|
||||
}
|
||||
|
||||
log.info("global event connected")
|
||||
return HttpServerResponse.raw(
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const write = (data: unknown) => {
|
||||
if (done) return
|
||||
try {
|
||||
controller.enqueue(encoder.encode(eventData(data)))
|
||||
} catch {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
const handler = (event: GlobalBusEvent) => write(event)
|
||||
unsubscribe = () => GlobalBus.off("event", handler)
|
||||
GlobalBus.on("event", handler)
|
||||
write({ payload: { type: "server.connected", properties: {} } })
|
||||
heartbeat = setInterval(() => write({ payload: { type: "server.heartbeat", properties: {} } }), 10_000)
|
||||
},
|
||||
cancel: cleanup,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Content-Type": "text/event-stream",
|
||||
"X-Accel-Buffering": "no",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const globalHandlers = HttpApiBuilder.group(GlobalApi, "global", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const installation = yield* Installation.Service
|
||||
|
||||
const health = Effect.fn("GlobalHttpApi.health")(function* () {
|
||||
return { healthy: true as const, version: InstallationVersion }
|
||||
})
|
||||
|
||||
const event = Effect.fn("GlobalHttpApi.event")(function* () {
|
||||
return eventResponse()
|
||||
})
|
||||
|
||||
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
|
||||
return yield* config.getGlobal()
|
||||
})
|
||||
|
||||
const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
|
||||
return yield* config.updateGlobal(ctx.payload)
|
||||
})
|
||||
|
||||
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
|
||||
yield* Effect.promise(() => Instance.disposeAll())
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: { type: "global.disposed", properties: {} },
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) {
|
||||
const method = yield* installation.method()
|
||||
if (method === "unknown") {
|
||||
return {
|
||||
status: 400,
|
||||
body: { success: false as const, error: "Unknown installation method" },
|
||||
}
|
||||
}
|
||||
const target = ctx.payload.target || (yield* installation.latest(method))
|
||||
const result = yield* installation.upgrade(method, target).pipe(
|
||||
Effect.as({ status: 200, body: { success: true as const, version: target } }),
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
status: 500,
|
||||
body: {
|
||||
success: false as const,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!result.body.success) return result
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Installation.Event.Updated.type,
|
||||
properties: { version: target },
|
||||
},
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: {
|
||||
request: HttpServerRequest.HttpServerRequest
|
||||
}) {
|
||||
const body = yield* Effect.orDie(ctx.request.text)
|
||||
const json = parseBody(body)
|
||||
if (json === undefined) {
|
||||
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
|
||||
}
|
||||
const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe(
|
||||
Effect.map((payload) => ({ valid: true as const, payload })),
|
||||
Effect.catch(() => Effect.succeed({ valid: false as const })),
|
||||
)
|
||||
if (!payload.valid) {
|
||||
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
|
||||
}
|
||||
const result = yield* upgrade({ payload: payload.payload })
|
||||
return HttpServerResponse.jsonUnsafe(result.body, { status: result.status })
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("health", health)
|
||||
.handleRaw("event", event)
|
||||
.handle("configGet", configGet)
|
||||
.handle("configUpdate", configUpdate)
|
||||
.handle("dispose", dispose)
|
||||
.handleRaw("upgrade", upgradeRaw)
|
||||
}),
|
||||
)
|
||||
+9
-35
@@ -1,10 +1,9 @@
|
||||
import { Config } from "@/config/config"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
import { markInstanceForDisposal } from "./lifecycle"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
import { InstanceContextMiddleware } from "../instance-context"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/config"
|
||||
|
||||
@@ -13,7 +12,7 @@ export const ConfigApi = HttpApi.make("config")
|
||||
HttpApiGroup.make("config")
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", root, {
|
||||
success: Config.Info,
|
||||
success: described(Config.Info, "Get config info"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "config.get",
|
||||
@@ -23,7 +22,8 @@ export const ConfigApi = HttpApi.make("config")
|
||||
),
|
||||
HttpApiEndpoint.patch("update", root, {
|
||||
payload: Config.Info,
|
||||
success: Config.Info,
|
||||
success: described(Config.Info, "Successfully updated config"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "config.update",
|
||||
@@ -32,7 +32,7 @@ export const ConfigApi = HttpApi.make("config")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("providers", `${root}/providers`, {
|
||||
success: Provider.ConfigProvidersResult,
|
||||
success: described(Provider.ConfigProvidersResult, "List of providers"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "config.providers",
|
||||
@@ -47,6 +47,7 @@ export const ConfigApi = HttpApi.make("config")
|
||||
description: "Experimental HttpApi config routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
@@ -56,30 +57,3 @@ export const ConfigApi = HttpApi.make("config")
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const configHandlers = HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const providerSvc = yield* Provider.Service
|
||||
const configSvc = yield* Config.Service
|
||||
|
||||
const get = Effect.fn("ConfigHttpApi.get")(function* () {
|
||||
return yield* configSvc.get()
|
||||
})
|
||||
|
||||
const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
|
||||
yield* configSvc.update(ctx.payload, { dispose: false })
|
||||
yield* markInstanceForDisposal(yield* InstanceState.context)
|
||||
return ctx.payload
|
||||
})
|
||||
|
||||
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
|
||||
const providers = yield* providerSvc.list()
|
||||
return {
|
||||
providers: Object.values(providers),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
}
|
||||
})
|
||||
|
||||
return handlers.handle("get", get).handle("update", update).handle("providers", providers)
|
||||
}),
|
||||
)
|
||||
+11
-35
@@ -1,8 +1,8 @@
|
||||
import { Auth } from "@/auth"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const AuthParams = Schema.Struct({
|
||||
providerID: ProviderID,
|
||||
@@ -13,7 +13,7 @@ const LogQuery = Schema.Struct({
|
||||
workspace: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const LogInput = Schema.Struct({
|
||||
export const LogInput = Schema.Struct({
|
||||
service: Schema.String.annotate({ description: "Service name for the log entry" }),
|
||||
level: Schema.Union([
|
||||
Schema.Literal("debug"),
|
||||
@@ -25,7 +25,7 @@ const LogInput = Schema.Struct({
|
||||
extra: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({
|
||||
description: "Additional metadata for the log entry",
|
||||
}),
|
||||
}).annotate({ identifier: "AppLogInput" })
|
||||
})
|
||||
|
||||
export const ControlPaths = {
|
||||
auth: "/auth/:providerID",
|
||||
@@ -38,7 +38,8 @@ export const ControlApi = HttpApi.make("control").add(
|
||||
HttpApiEndpoint.put("authSet", ControlPaths.auth, {
|
||||
params: AuthParams,
|
||||
payload: Auth.Info,
|
||||
success: Schema.Boolean,
|
||||
success: described(Schema.Boolean, "Successfully set authentication credentials"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "auth.set",
|
||||
@@ -48,7 +49,8 @@ export const ControlApi = HttpApi.make("control").add(
|
||||
),
|
||||
HttpApiEndpoint.delete("authRemove", ControlPaths.auth, {
|
||||
params: AuthParams,
|
||||
success: Schema.Boolean,
|
||||
success: described(Schema.Boolean, "Successfully removed authentication credentials"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "auth.remove",
|
||||
@@ -59,7 +61,8 @@ export const ControlApi = HttpApi.make("control").add(
|
||||
HttpApiEndpoint.post("log", ControlPaths.log, {
|
||||
query: LogQuery,
|
||||
payload: LogInput,
|
||||
success: Schema.Boolean,
|
||||
success: described(Schema.Boolean, "Log entry written successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "app.log",
|
||||
@@ -70,30 +73,3 @@ export const ControlApi = HttpApi.make("control").add(
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })),
|
||||
)
|
||||
|
||||
export const controlHandlers = HttpApiBuilder.group(ControlApi, "control", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
|
||||
const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: {
|
||||
params: { providerID: ProviderID }
|
||||
payload: Auth.Info
|
||||
}) {
|
||||
yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
|
||||
return true
|
||||
})
|
||||
|
||||
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) {
|
||||
yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
|
||||
return true
|
||||
})
|
||||
|
||||
const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) {
|
||||
const logger = Log.create({ service: ctx.payload.service })
|
||||
logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra)
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log)
|
||||
}),
|
||||
)
|
||||
+32
-171
@@ -1,24 +1,19 @@
|
||||
import { Account } from "@/account/account"
|
||||
import { AccountID, OrgID } from "@/account/schema"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Project } from "@/project/project"
|
||||
import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { Session } from "@/session/session"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import * as EffectZod from "@/util/effect-zod"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
import { InstanceContextMiddleware } from "../instance-context"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const ConsoleStateResponse = Schema.Struct({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
activeOrgName: Schema.optionalKey(Schema.String),
|
||||
switchableOrgCount: Schema.Number,
|
||||
switchableOrgCount: NonNegativeInt,
|
||||
}).annotate({ identifier: "ConsoleState" })
|
||||
|
||||
const ConsoleOrgOption = Schema.Struct({
|
||||
@@ -28,25 +23,25 @@ const ConsoleOrgOption = Schema.Struct({
|
||||
orgID: Schema.String,
|
||||
orgName: Schema.String,
|
||||
active: Schema.Boolean,
|
||||
}).annotate({ identifier: "ConsoleOrgOption" })
|
||||
})
|
||||
|
||||
const ConsoleOrgList = Schema.Struct({
|
||||
orgs: Schema.Array(ConsoleOrgOption),
|
||||
}).annotate({ identifier: "ConsoleOrgList" })
|
||||
})
|
||||
|
||||
const ConsoleSwitchPayload = Schema.Struct({
|
||||
export const ConsoleSwitchPayload = Schema.Struct({
|
||||
accountID: AccountID,
|
||||
orgID: OrgID,
|
||||
}).annotate({ identifier: "ConsoleSwitchInput" })
|
||||
})
|
||||
|
||||
const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" })
|
||||
const ToolListItem = Schema.Struct({
|
||||
id: Schema.String,
|
||||
description: Schema.String,
|
||||
parameters: Schema.Record(Schema.String, Schema.Any),
|
||||
parameters: Schema.Unknown,
|
||||
}).annotate({ identifier: "ToolListItem" })
|
||||
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
|
||||
const ToolListQuery = Schema.Struct({
|
||||
export const ToolListQuery = Schema.Struct({
|
||||
provider: ProviderID,
|
||||
model: ModelID,
|
||||
})
|
||||
@@ -57,8 +52,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" })
|
||||
const SessionListQuery = Schema.Struct({
|
||||
const WorktreeList = Schema.Array(Schema.String)
|
||||
export const SessionListQuery = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
roots: Schema.optional(QueryBoolean),
|
||||
start: Schema.optional(Schema.NumberFromString),
|
||||
@@ -85,7 +80,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
HttpApiGroup.make("experimental")
|
||||
.add(
|
||||
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
|
||||
success: ConsoleStateResponse,
|
||||
success: described(ConsoleStateResponse, "Active Console provider metadata"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.console.get",
|
||||
@@ -94,7 +89,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
|
||||
success: ConsoleOrgList,
|
||||
success: described(ConsoleOrgList, "Switchable Console orgs"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.console.listOrgs",
|
||||
@@ -104,7 +99,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
),
|
||||
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
|
||||
payload: ConsoleSwitchPayload,
|
||||
success: Schema.Boolean,
|
||||
success: described(Schema.Boolean, "Switch success"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -115,7 +110,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
),
|
||||
HttpApiEndpoint.get("tool", ExperimentalPaths.tool, {
|
||||
query: ToolListQuery,
|
||||
success: ToolList,
|
||||
success: described(ToolList, "Tools"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tool.list",
|
||||
@@ -125,7 +121,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
|
||||
success: ToolIDs,
|
||||
success: described(ToolIDs, "Tool IDs"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tool.ids",
|
||||
@@ -135,7 +132,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
|
||||
success: WorktreeList,
|
||||
success: described(WorktreeList, "List of worktree directories"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "worktree.list",
|
||||
@@ -145,7 +142,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
),
|
||||
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
|
||||
payload: Schema.optional(Worktree.CreateInput),
|
||||
success: Worktree.Info,
|
||||
success: described(Worktree.Info, "Worktree created"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "worktree.create",
|
||||
@@ -155,7 +153,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
),
|
||||
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
|
||||
payload: Worktree.RemoveInput,
|
||||
success: Schema.Boolean,
|
||||
success: described(Schema.Boolean, "Worktree removed"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "worktree.remove",
|
||||
@@ -165,7 +164,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
),
|
||||
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
|
||||
payload: Worktree.ResetInput,
|
||||
success: Schema.Boolean,
|
||||
success: described(Schema.Boolean, "Worktree reset"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "worktree.reset",
|
||||
@@ -175,7 +175,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
),
|
||||
HttpApiEndpoint.get("session", ExperimentalPaths.session, {
|
||||
query: SessionListQuery,
|
||||
success: Schema.Array(Session.GlobalInfo),
|
||||
success: described(Schema.Array(Session.GlobalInfo), "List of sessions"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.session.list",
|
||||
@@ -185,7 +185,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
|
||||
success: Schema.Record(Schema.String, MCP.Resource),
|
||||
success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.resource.list",
|
||||
@@ -200,6 +200,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
description: "Experimental HttpApi read-only routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
@@ -209,143 +210,3 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const experimentalHandlers = HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const account = yield* Account.Service
|
||||
const agents = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const project = yield* Project.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const worktreeSvc = yield* Worktree.Service
|
||||
|
||||
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
|
||||
const [state, groups] = yield* Effect.all(
|
||||
[config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)],
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
)
|
||||
return {
|
||||
consoleManagedProviders: state.consoleManagedProviders,
|
||||
...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}),
|
||||
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
||||
}
|
||||
})
|
||||
|
||||
const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () {
|
||||
const [groups, active] = yield* Effect.all(
|
||||
[account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)],
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
)
|
||||
const info = Option.getOrUndefined(active)
|
||||
return {
|
||||
orgs: groups.flatMap((group) =>
|
||||
group.orgs.map((org) => ({
|
||||
accountID: group.account.id,
|
||||
accountEmail: group.account.email,
|
||||
accountUrl: group.account.url,
|
||||
orgID: org.id,
|
||||
orgName: org.name,
|
||||
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
|
||||
})),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: {
|
||||
payload: typeof ConsoleSwitchPayload.Type
|
||||
}) {
|
||||
yield* account
|
||||
.use(ctx.payload.accountID, Option.some(ctx.payload.orgID))
|
||||
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
|
||||
return true
|
||||
})
|
||||
|
||||
const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) {
|
||||
const list = yield* registry.tools({
|
||||
providerID: ctx.query.provider,
|
||||
modelID: ctx.query.model,
|
||||
agent: yield* agents.get(yield* agents.defaultAgent()),
|
||||
})
|
||||
return list.map((item) => ({
|
||||
id: item.id,
|
||||
description: item.description,
|
||||
parameters: EffectZod.toJsonSchema(item.parameters),
|
||||
}))
|
||||
})
|
||||
|
||||
const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () {
|
||||
return yield* registry.ids()
|
||||
})
|
||||
|
||||
const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
return yield* project.sandboxes(ctx.project.id)
|
||||
})
|
||||
|
||||
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
|
||||
payload: Worktree.CreateInput | undefined
|
||||
}) {
|
||||
return yield* worktreeSvc.create(ctx.payload)
|
||||
})
|
||||
|
||||
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
|
||||
payload: Worktree.RemoveInput
|
||||
}) {
|
||||
const ctx = yield* InstanceState.context
|
||||
yield* worktreeSvc.remove(input.payload)
|
||||
yield* project.removeSandbox(ctx.project.id, input.payload.directory)
|
||||
return true
|
||||
})
|
||||
|
||||
const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: {
|
||||
payload: Worktree.ResetInput
|
||||
}) {
|
||||
yield* worktreeSvc.reset(ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
|
||||
const limit = ctx.query.limit ?? 100
|
||||
const sessions = Array.from(
|
||||
Session.listGlobal({
|
||||
directory: ctx.query.directory,
|
||||
roots: ctx.query.roots,
|
||||
start: ctx.query.start,
|
||||
cursor: ctx.query.cursor,
|
||||
search: ctx.query.search,
|
||||
limit: limit + 1,
|
||||
archived: ctx.query.archived,
|
||||
}),
|
||||
)
|
||||
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
|
||||
return HttpServerResponse.jsonUnsafe(list, {
|
||||
headers:
|
||||
sessions.length > limit && list.length > 0
|
||||
? { "x-next-cursor": String(list[list.length - 1].time.updated) }
|
||||
: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
|
||||
return yield* mcp.resources()
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("console", getConsole)
|
||||
.handle("consoleOrgs", listConsoleOrgs)
|
||||
.handle("consoleSwitch", switchConsole)
|
||||
.handle("tool", tool)
|
||||
.handle("toolIDs", toolIDs)
|
||||
.handle("worktree", worktree)
|
||||
.handle("worktreeCreate", worktreeCreate)
|
||||
.handle("worktreeRemove", worktreeRemove)
|
||||
.handle("worktreeReset", worktreeReset)
|
||||
.handle("session", session)
|
||||
.handle("resource", resource)
|
||||
}),
|
||||
)
|
||||
+16
-62
@@ -1,20 +1,21 @@
|
||||
import { File } from "@/file"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
import { InstanceContextMiddleware } from "../instance-context"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const FileQuery = Schema.Struct({
|
||||
export const FileQuery = Schema.Struct({
|
||||
path: Schema.String,
|
||||
})
|
||||
|
||||
const FindTextQuery = Schema.Struct({
|
||||
export const FindTextQuery = Schema.Struct({
|
||||
pattern: Schema.String,
|
||||
})
|
||||
|
||||
const FindFileQuery = Schema.Struct({
|
||||
export const FindFileQuery = Schema.Struct({
|
||||
query: Schema.String,
|
||||
dirs: Schema.optional(Schema.Literals(["true", "false"])),
|
||||
type: Schema.optional(Schema.Literals(["file", "directory"])),
|
||||
@@ -23,7 +24,7 @@ const FindFileQuery = Schema.Struct({
|
||||
),
|
||||
})
|
||||
|
||||
const FindSymbolQuery = Schema.Struct({
|
||||
export const FindSymbolQuery = Schema.Struct({
|
||||
query: Schema.String,
|
||||
})
|
||||
|
||||
@@ -42,7 +43,7 @@ export const FileApi = HttpApi.make("file")
|
||||
.add(
|
||||
HttpApiEndpoint.get("findText", FilePaths.findText, {
|
||||
query: FindTextQuery,
|
||||
success: Schema.Array(Ripgrep.SearchMatch),
|
||||
success: described(Schema.Array(Ripgrep.SearchMatch), "Matches"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "find.text",
|
||||
@@ -52,7 +53,7 @@ export const FileApi = HttpApi.make("file")
|
||||
),
|
||||
HttpApiEndpoint.get("findFile", FilePaths.findFile, {
|
||||
query: FindFileQuery,
|
||||
success: Schema.Array(Schema.String),
|
||||
success: described(Schema.Array(Schema.String), "File paths"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "find.files",
|
||||
@@ -62,7 +63,7 @@ export const FileApi = HttpApi.make("file")
|
||||
),
|
||||
HttpApiEndpoint.get("findSymbol", FilePaths.findSymbol, {
|
||||
query: FindSymbolQuery,
|
||||
success: Schema.Array(LSP.Symbol),
|
||||
success: described(Schema.Array(LSP.Symbol), "Symbols"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "find.symbols",
|
||||
@@ -72,7 +73,7 @@ export const FileApi = HttpApi.make("file")
|
||||
),
|
||||
HttpApiEndpoint.get("list", FilePaths.list, {
|
||||
query: FileQuery,
|
||||
success: Schema.Array(File.Node),
|
||||
success: described(Schema.Array(File.Node), "Files and directories"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "file.list",
|
||||
@@ -82,7 +83,7 @@ export const FileApi = HttpApi.make("file")
|
||||
),
|
||||
HttpApiEndpoint.get("content", FilePaths.content, {
|
||||
query: FileQuery,
|
||||
success: File.Content,
|
||||
success: described(File.Content, "File content"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "file.read",
|
||||
@@ -91,7 +92,7 @@ export const FileApi = HttpApi.make("file")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", FilePaths.status, {
|
||||
success: Schema.Array(File.Info),
|
||||
success: described(Schema.Array(File.Info), "File status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "file.status",
|
||||
@@ -106,6 +107,7 @@ export const FileApi = HttpApi.make("file")
|
||||
description: "Experimental HttpApi file routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
@@ -115,51 +117,3 @@ export const FileApi = HttpApi.make("file")
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const fileHandlers = HttpApiBuilder.group(FileApi, "file", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* File.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
|
||||
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
|
||||
return (yield* ripgrep
|
||||
.search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 })
|
||||
.pipe(Effect.orDie)).items
|
||||
})
|
||||
|
||||
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
|
||||
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
|
||||
}) {
|
||||
return yield* svc.search({
|
||||
query: ctx.query.query,
|
||||
limit: ctx.query.limit ?? 10,
|
||||
dirs: ctx.query.dirs !== "false",
|
||||
type: ctx.query.type,
|
||||
})
|
||||
})
|
||||
|
||||
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
|
||||
return []
|
||||
})
|
||||
|
||||
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
|
||||
return yield* svc.list(ctx.query.path)
|
||||
})
|
||||
|
||||
const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
|
||||
return yield* svc.read(ctx.query.path)
|
||||
})
|
||||
|
||||
const status = Effect.fn("FileHttpApi.status")(function* () {
|
||||
return yield* svc.status()
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("findText", findText)
|
||||
.handle("findFile", findFile)
|
||||
.handle("findSymbol", findSymbol)
|
||||
.handle("list", list)
|
||||
.handle("content", content)
|
||||
.handle("status", status)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Config } from "@/config/config"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const GlobalHealth = Schema.Struct({
|
||||
healthy: Schema.Literal(true),
|
||||
version: Schema.String,
|
||||
})
|
||||
|
||||
const GlobalEventSchema = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
project: Schema.optional(Schema.String),
|
||||
workspace: Schema.optional(Schema.String),
|
||||
payload: Schema.Union([...BusEvent.effectPayloads(), ...SyncEvent.effectPayloads()]),
|
||||
}).annotate({ identifier: "GlobalEvent" })
|
||||
|
||||
export const GlobalUpgradeInput = Schema.Struct({
|
||||
target: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GlobalUpgradeResult = Schema.Union([
|
||||
Schema.Struct({
|
||||
success: Schema.Literal(true),
|
||||
version: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
success: Schema.Literal(false),
|
||||
error: Schema.String,
|
||||
}),
|
||||
])
|
||||
|
||||
export const GlobalPaths = {
|
||||
health: "/global/health",
|
||||
event: "/global/event",
|
||||
config: "/global/config",
|
||||
dispose: "/global/dispose",
|
||||
upgrade: "/global/upgrade",
|
||||
} as const
|
||||
|
||||
export const GlobalApi = HttpApi.make("global").add(
|
||||
HttpApiGroup.make("global")
|
||||
.add(
|
||||
HttpApiEndpoint.get("health", GlobalPaths.health, {
|
||||
success: described(GlobalHealth, "Health information"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.health",
|
||||
summary: "Get health",
|
||||
description: "Get health information about the OpenCode server.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("event", GlobalPaths.event, {
|
||||
success: GlobalEventSchema,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.event",
|
||||
summary: "Get global events",
|
||||
description: "Subscribe to global events from the OpenCode system using server-sent events.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("configGet", GlobalPaths.config, {
|
||||
success: described(Config.Info, "Get global config info"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.config.get",
|
||||
summary: "Get global configuration",
|
||||
description: "Retrieve the current global OpenCode configuration settings and preferences.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, {
|
||||
payload: Config.Info,
|
||||
success: described(Config.Info, "Successfully updated global config"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.config.update",
|
||||
summary: "Update global configuration",
|
||||
description: "Update global OpenCode configuration settings and preferences.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("dispose", GlobalPaths.dispose, {
|
||||
success: described(Schema.Boolean, "Global disposed"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.dispose",
|
||||
summary: "Dispose instance",
|
||||
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, {
|
||||
payload: GlobalUpgradeInput,
|
||||
success: described(GlobalUpgradeResult, "Upgrade result"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.upgrade",
|
||||
summary: "Upgrade opencode",
|
||||
description: "Upgrade opencode to the specified version or latest if not specified.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })),
|
||||
)
|
||||
+15
-82
@@ -1,15 +1,14 @@
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Command } from "@/command"
|
||||
import { Format } from "@/format"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Skill } from "@/skill"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
import { markInstanceForDisposal } from "./lifecycle"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
import { InstanceContextMiddleware } from "../instance-context"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const PathInfo = Schema.Struct({
|
||||
home: Schema.String,
|
||||
@@ -19,7 +18,7 @@ const PathInfo = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
}).annotate({ identifier: "Path" })
|
||||
|
||||
const VcsDiffQuery = Schema.Struct({
|
||||
export const VcsDiffQuery = Schema.Struct({
|
||||
mode: Vcs.Mode,
|
||||
})
|
||||
|
||||
@@ -40,7 +39,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
HttpApiGroup.make("instance")
|
||||
.add(
|
||||
HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
|
||||
success: Schema.Boolean,
|
||||
success: described(Schema.Boolean, "Instance disposed"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "instance.dispose",
|
||||
@@ -59,7 +58,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
|
||||
success: Vcs.Info,
|
||||
success: described(Vcs.Info, "VCS info"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "vcs.get",
|
||||
@@ -70,7 +69,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
),
|
||||
HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
|
||||
query: VcsDiffQuery,
|
||||
success: Schema.Array(Vcs.FileDiff),
|
||||
success: described(Schema.Array(Vcs.FileDiff), "VCS diff"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "vcs.diff",
|
||||
@@ -79,7 +78,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("command", InstancePaths.command, {
|
||||
success: Schema.Array(Command.Info),
|
||||
success: described(Schema.Array(Command.Info), "List of commands"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "command.list",
|
||||
@@ -88,7 +87,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("agent", InstancePaths.agent, {
|
||||
success: Schema.Array(Agent.Info),
|
||||
success: described(Schema.Array(Agent.Info), "List of agents"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "app.agents",
|
||||
@@ -97,7 +96,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("skill", InstancePaths.skill, {
|
||||
success: Schema.Array(Skill.Info),
|
||||
success: described(Schema.Array(Skill.Info), "List of skills"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "app.skills",
|
||||
@@ -106,7 +105,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
|
||||
success: Schema.Array(LSP.Status),
|
||||
success: described(Schema.Array(LSP.Status), "LSP server status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "lsp.status",
|
||||
@@ -115,7 +114,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
|
||||
success: Schema.Array(Format.Status),
|
||||
success: described(Schema.Array(Format.Status), "Formatter status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "formatter.status",
|
||||
@@ -130,6 +129,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
description: "Experimental HttpApi instance read routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
@@ -139,70 +139,3 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const instanceHandlers = HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const command = yield* Command.Service
|
||||
const format = yield* Format.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const skill = yield* Skill.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
|
||||
const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () {
|
||||
yield* markInstanceForDisposal(yield* InstanceState.context)
|
||||
return true
|
||||
})
|
||||
|
||||
const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
return {
|
||||
home: Global.Path.home,
|
||||
state: Global.Path.state,
|
||||
config: Global.Path.config,
|
||||
worktree: ctx.worktree,
|
||||
directory: ctx.directory,
|
||||
}
|
||||
})
|
||||
|
||||
const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () {
|
||||
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
|
||||
return { branch, default_branch }
|
||||
})
|
||||
|
||||
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
|
||||
return yield* vcs.diff(ctx.query.mode)
|
||||
})
|
||||
|
||||
const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
|
||||
return yield* command.list()
|
||||
})
|
||||
|
||||
const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () {
|
||||
return yield* agent.list()
|
||||
})
|
||||
|
||||
const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () {
|
||||
return yield* skill.all()
|
||||
})
|
||||
|
||||
const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () {
|
||||
return yield* lsp.status()
|
||||
})
|
||||
|
||||
const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () {
|
||||
return yield* format.status()
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("dispose", dispose)
|
||||
.handle("path", getPath)
|
||||
.handle("vcs", getVcs)
|
||||
.handle("vcsDiff", getVcsDiff)
|
||||
.handle("command", getCommand)
|
||||
.handle("agent", getAgent)
|
||||
.handle("skill", getSkill)
|
||||
.handle("lsp", getLsp)
|
||||
.handle("formatter", getFormatter)
|
||||
}),
|
||||
)
|
||||
+28
-87
@@ -1,26 +1,27 @@
|
||||
import { MCP } from "@/mcp"
|
||||
import { ConfigMCP } from "@/config/mcp"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
import { InstanceContextMiddleware } from "../instance-context"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const AddPayload = Schema.Struct({
|
||||
export const AddPayload = Schema.Struct({
|
||||
name: Schema.String,
|
||||
config: ConfigMCP.Info,
|
||||
}).annotate({ identifier: "McpAddInput" })
|
||||
})
|
||||
|
||||
const StatusMap = Schema.Record(Schema.String, MCP.Status)
|
||||
const AuthStartResponse = Schema.Struct({
|
||||
export const StatusMap = Schema.Record(Schema.String, MCP.Status)
|
||||
export const AuthStartResponse = Schema.Struct({
|
||||
authorizationUrl: Schema.String,
|
||||
oauthState: Schema.String,
|
||||
}).annotate({ identifier: "McpAuthStartResponse" })
|
||||
const AuthCallbackPayload = Schema.Struct({
|
||||
})
|
||||
export const AuthCallbackPayload = Schema.Struct({
|
||||
code: Schema.String,
|
||||
}).annotate({ identifier: "McpAuthCallbackInput" })
|
||||
const AuthRemoveResponse = Schema.Struct({
|
||||
})
|
||||
export const AuthRemoveResponse = Schema.Struct({
|
||||
success: Schema.Literal(true),
|
||||
}).annotate({ identifier: "McpAuthRemoveResponse" })
|
||||
class UnsupportedOAuthError extends Schema.ErrorClass<UnsupportedOAuthError>("McpUnsupportedOAuthError")(
|
||||
})
|
||||
export class UnsupportedOAuthError extends Schema.ErrorClass<UnsupportedOAuthError>("McpUnsupportedOAuthError")(
|
||||
{ error: Schema.String },
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
@@ -39,7 +40,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
HttpApiGroup.make("mcp")
|
||||
.add(
|
||||
HttpApiEndpoint.get("status", McpPaths.status, {
|
||||
success: Schema.Record(Schema.String, MCP.Status),
|
||||
success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.status",
|
||||
@@ -49,7 +50,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("add", McpPaths.status, {
|
||||
payload: AddPayload,
|
||||
success: StatusMap,
|
||||
success: described(StatusMap, "MCP server added successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -60,8 +61,8 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("authStart", McpPaths.auth, {
|
||||
params: { name: Schema.String },
|
||||
success: AuthStartResponse,
|
||||
error: UnsupportedOAuthError,
|
||||
success: described(AuthStartResponse, "OAuth flow started"),
|
||||
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.auth.start",
|
||||
@@ -72,7 +73,8 @@ export const McpApi = HttpApi.make("mcp")
|
||||
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
|
||||
params: { name: Schema.String },
|
||||
payload: AuthCallbackPayload,
|
||||
success: MCP.Status,
|
||||
success: described(MCP.Status, "OAuth authentication completed"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.auth.callback",
|
||||
@@ -83,8 +85,8 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
|
||||
params: { name: Schema.String },
|
||||
success: MCP.Status,
|
||||
error: UnsupportedOAuthError,
|
||||
success: described(MCP.Status, "OAuth authentication completed"),
|
||||
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.auth.authenticate",
|
||||
@@ -94,7 +96,8 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
|
||||
params: { name: Schema.String },
|
||||
success: AuthRemoveResponse,
|
||||
success: described(AuthRemoveResponse, "OAuth credentials removed"),
|
||||
error: HttpApiError.NotFound,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.auth.remove",
|
||||
@@ -104,7 +107,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("connect", McpPaths.connect, {
|
||||
params: { name: Schema.String },
|
||||
success: Schema.Boolean,
|
||||
success: described(Schema.Boolean, "MCP server connected successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.connect",
|
||||
@@ -113,7 +116,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
|
||||
params: { name: Schema.String },
|
||||
success: Schema.Boolean,
|
||||
success: described(Schema.Boolean, "MCP server disconnected successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "mcp.disconnect",
|
||||
@@ -127,6 +130,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
description: "Experimental HttpApi MCP routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
@@ -136,66 +140,3 @@ export const McpApi = HttpApi.make("mcp")
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const mcpHandlers = HttpApiBuilder.group(McpApi, "mcp", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
|
||||
const status = Effect.fn("McpHttpApi.status")(function* () {
|
||||
return yield* mcp.status()
|
||||
})
|
||||
|
||||
const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) {
|
||||
const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status
|
||||
return yield* Schema.decodeUnknownEffect(StatusMap)(
|
||||
"status" in result ? { [ctx.payload.name]: result } : result,
|
||||
).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
})
|
||||
|
||||
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
|
||||
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
|
||||
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
|
||||
}
|
||||
return yield* mcp.startAuth(ctx.params.name)
|
||||
})
|
||||
|
||||
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
|
||||
params: { name: string }
|
||||
payload: typeof AuthCallbackPayload.Type
|
||||
}) {
|
||||
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
|
||||
})
|
||||
|
||||
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
|
||||
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
|
||||
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
|
||||
}
|
||||
return yield* mcp.authenticate(ctx.params.name)
|
||||
})
|
||||
|
||||
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
|
||||
yield* mcp.removeAuth(ctx.params.name)
|
||||
return { success: true as const }
|
||||
})
|
||||
|
||||
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
|
||||
yield* mcp.connect(ctx.params.name)
|
||||
return true
|
||||
})
|
||||
|
||||
const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
|
||||
yield* mcp.disconnect(ctx.params.name)
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("status", status)
|
||||
.handle("add", add)
|
||||
.handle("authStart", authStart)
|
||||
.handle("authCallback", authCallback)
|
||||
.handle("authAuthenticate", authAuthenticate)
|
||||
.handle("authRemove", authRemove)
|
||||
.handle("connect", connect)
|
||||
.handle("disconnect", disconnect)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Schema } from "effect"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
export function described<S extends Schema.Top>(schema: S, description: string): S {
|
||||
return schema.annotate({ description }) as S
|
||||
}
|
||||
|
||||
export function responseDescription(description: string) {
|
||||
return OpenApi.annotations({
|
||||
transform: (operation) => {
|
||||
const response = operation.responses?.["200"]
|
||||
if (response && typeof response === "object" && "description" in response) {
|
||||
response.description = description
|
||||
}
|
||||
return operation
|
||||
},
|
||||
})
|
||||
}
|
||||
+14
-30
@@ -1,17 +1,23 @@
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
import { InstanceContextMiddleware } from "../instance-context"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/permission"
|
||||
const ReplyPayload = Schema.Struct({
|
||||
reply: Permission.Reply,
|
||||
message: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const PermissionApi = HttpApi.make("permission")
|
||||
.add(
|
||||
HttpApiGroup.make("permission")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
success: Schema.Array(Permission.Request),
|
||||
success: described(Schema.Array(Permission.Request), "List of pending permissions"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "permission.list",
|
||||
@@ -21,8 +27,9 @@ export const PermissionApi = HttpApi.make("permission")
|
||||
),
|
||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||
params: { requestID: PermissionID },
|
||||
payload: Permission.ReplyBody,
|
||||
success: Schema.Boolean,
|
||||
payload: ReplyPayload,
|
||||
success: described(Schema.Boolean, "Permission processed successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "permission.reply",
|
||||
@@ -37,6 +44,7 @@ export const PermissionApi = HttpApi.make("permission")
|
||||
description: "Experimental HttpApi permission routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
@@ -46,27 +54,3 @@ export const PermissionApi = HttpApi.make("permission")
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const permissionHandlers = HttpApiBuilder.group(PermissionApi, "permission", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Permission.Service
|
||||
|
||||
const list = Effect.fn("PermissionHttpApi.list")(function* () {
|
||||
return yield* svc.list()
|
||||
})
|
||||
|
||||
const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
|
||||
params: { requestID: PermissionID }
|
||||
payload: Permission.ReplyBody
|
||||
}) {
|
||||
yield* svc.reply({
|
||||
requestID: ctx.params.requestID,
|
||||
reply: ctx.payload.reply,
|
||||
message: ctx.payload.message,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("reply", reply)
|
||||
}),
|
||||
)
|
||||
+17
-49
@@ -1,21 +1,24 @@
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Project } from "@/project/project"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
import { markInstanceForReload } from "./lifecycle"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
import { InstanceContextMiddleware } from "../instance-context"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/project"
|
||||
const UpdatePayload = Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
icon: Schema.optional(Project.Info.fields.icon),
|
||||
commands: Schema.optional(Project.Info.fields.commands),
|
||||
})
|
||||
|
||||
export const ProjectApi = HttpApi.make("project")
|
||||
.add(
|
||||
HttpApiGroup.make("project")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
success: Schema.Array(Project.Info),
|
||||
success: described(Schema.Array(Project.Info), "List of projects"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "project.list",
|
||||
@@ -24,7 +27,7 @@ export const ProjectApi = HttpApi.make("project")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("current", `${root}/current`, {
|
||||
success: Project.Info,
|
||||
success: described(Project.Info, "Current project information"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "project.current",
|
||||
@@ -33,7 +36,7 @@ export const ProjectApi = HttpApi.make("project")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("initGit", `${root}/git/init`, {
|
||||
success: Project.Info,
|
||||
success: described(Project.Info, "Project information after git initialization"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "project.initGit",
|
||||
@@ -43,8 +46,9 @@ export const ProjectApi = HttpApi.make("project")
|
||||
),
|
||||
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
|
||||
params: { projectID: ProjectID },
|
||||
payload: Project.UpdatePayload,
|
||||
success: Project.Info,
|
||||
payload: UpdatePayload,
|
||||
success: described(Project.Info, "Updated project information"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "project.update",
|
||||
@@ -59,6 +63,7 @@ export const ProjectApi = HttpApi.make("project")
|
||||
description: "Experimental HttpApi project routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
@@ -68,40 +73,3 @@ export const ProjectApi = HttpApi.make("project")
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const projectHandlers = HttpApiBuilder.group(ProjectApi, "project", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Project.Service
|
||||
|
||||
const list = Effect.fn("ProjectHttpApi.list")(function* () {
|
||||
return yield* svc.list()
|
||||
})
|
||||
|
||||
const current = Effect.fn("ProjectHttpApi.current")(function* () {
|
||||
return (yield* InstanceState.context).project
|
||||
})
|
||||
|
||||
const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project })
|
||||
if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree)
|
||||
return next
|
||||
yield* markInstanceForReload(ctx, {
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.directory,
|
||||
project: next,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
})
|
||||
return next
|
||||
})
|
||||
|
||||
const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: {
|
||||
params: { projectID: ProjectID }
|
||||
payload: Project.UpdatePayload
|
||||
}) {
|
||||
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID })
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
import { InstanceContextMiddleware } from "../instance-context"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/provider"
|
||||
|
||||
export const ProviderApi = HttpApi.make("provider")
|
||||
.add(
|
||||
HttpApiGroup.make("provider")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
success: described(Provider.ListResult, "List of providers"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "provider.list",
|
||||
summary: "List providers",
|
||||
description: "Get a list of all available AI providers, including both available and connected ones.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("auth", `${root}/auth`, {
|
||||
success: described(ProviderAuth.Methods, "Provider auth methods"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "provider.auth",
|
||||
summary: "Get provider auth methods",
|
||||
description: "Retrieve available authentication methods for all AI providers.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
|
||||
params: { providerID: ProviderID },
|
||||
payload: ProviderAuth.AuthorizeInput,
|
||||
success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "provider.oauth.authorize",
|
||||
summary: "Start OAuth authorization",
|
||||
description: "Start the OAuth authorization flow for a provider.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
|
||||
params: { providerID: ProviderID },
|
||||
payload: ProviderAuth.CallbackInput,
|
||||
success: described(Schema.Boolean, "OAuth callback processed successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "provider.oauth.callback",
|
||||
summary: "Handle OAuth callback",
|
||||
description: "Handle the OAuth callback from a provider after user authorization.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "provider",
|
||||
description: "Experimental HttpApi provider routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
import { InstanceContextMiddleware } from "../instance-context"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/pty"
|
||||
export const Params = Schema.Struct({ ptyID: PtyID })
|
||||
export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) })
|
||||
export const ShellItem = Schema.Struct({
|
||||
path: Schema.String,
|
||||
name: Schema.String,
|
||||
acceptable: Schema.Boolean,
|
||||
})
|
||||
|
||||
export const PtyPaths = {
|
||||
shells: `${root}/shells`,
|
||||
list: root,
|
||||
create: root,
|
||||
get: `${root}/:ptyID`,
|
||||
update: `${root}/:ptyID`,
|
||||
remove: `${root}/:ptyID`,
|
||||
connect: `${root}/:ptyID/connect`,
|
||||
} as const
|
||||
|
||||
export const PtyApi = HttpApi.make("pty")
|
||||
.add(
|
||||
HttpApiGroup.make("pty")
|
||||
.add(
|
||||
HttpApiEndpoint.get("shells", PtyPaths.shells, { success: described(Schema.Array(ShellItem), "List of shells") }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.shells",
|
||||
summary: "List available shells",
|
||||
description: "Get a list of available shells on the system.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("list", PtyPaths.list, { success: described(Schema.Array(Pty.Info), "List of sessions") }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.list",
|
||||
summary: "List PTY sessions",
|
||||
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", PtyPaths.create, {
|
||||
payload: Pty.CreateInput,
|
||||
success: described(Pty.Info, "Created session"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.create",
|
||||
summary: "Create PTY session",
|
||||
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("get", PtyPaths.get, {
|
||||
params: { ptyID: PtyID },
|
||||
success: described(Pty.Info, "Session info"),
|
||||
error: HttpApiError.NotFound,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.get",
|
||||
summary: "Get PTY session",
|
||||
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.put("update", PtyPaths.update, {
|
||||
params: { ptyID: PtyID },
|
||||
payload: Pty.UpdateInput,
|
||||
success: described(Pty.Info, "Updated session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.update",
|
||||
summary: "Update PTY session",
|
||||
description: "Update properties of an existing pseudo-terminal (PTY) session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
|
||||
params: { ptyID: PtyID },
|
||||
success: described(Schema.Boolean, "Session removed"),
|
||||
error: HttpApiError.NotFound,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.remove",
|
||||
summary: "Remove PTY session",
|
||||
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." }))
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const PtyConnectApi = HttpApi.make("pty-connect").add(
|
||||
HttpApiGroup.make("pty-connect")
|
||||
.add(
|
||||
HttpApiEndpoint.get("connect", PtyPaths.connect, {
|
||||
params: Params,
|
||||
success: described(Schema.Boolean, "Connected session"),
|
||||
error: HttpApiError.NotFound,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.connect",
|
||||
summary: "Connect to PTY session",
|
||||
description:
|
||||
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
|
||||
)
|
||||
+17
-35
@@ -1,17 +1,24 @@
|
||||
import { Question } from "@/question"
|
||||
import { QuestionID } from "@/question/schema"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
import { InstanceContextMiddleware } from "../instance-context"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/question"
|
||||
const ReplyPayload = Schema.Struct({
|
||||
answers: Schema.Array(Question.Answer).annotate({
|
||||
description: "User answers in order of questions (each answer is an array of selected labels)",
|
||||
}),
|
||||
})
|
||||
|
||||
export const QuestionApi = HttpApi.make("question")
|
||||
.add(
|
||||
HttpApiGroup.make("question")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
success: Schema.Array(Question.Request),
|
||||
success: described(Schema.Array(Question.Request), "List of pending questions"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.list",
|
||||
@@ -21,8 +28,9 @@ export const QuestionApi = HttpApi.make("question")
|
||||
),
|
||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||
params: { requestID: QuestionID },
|
||||
payload: Question.Reply,
|
||||
success: Schema.Boolean,
|
||||
payload: ReplyPayload,
|
||||
success: described(Schema.Boolean, "Question answered successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.reply",
|
||||
@@ -32,7 +40,8 @@ export const QuestionApi = HttpApi.make("question")
|
||||
),
|
||||
HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
|
||||
params: { requestID: QuestionID },
|
||||
success: Schema.Boolean,
|
||||
success: described(Schema.Boolean, "Question rejected successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.reject",
|
||||
@@ -47,6 +56,7 @@ export const QuestionApi = HttpApi.make("question")
|
||||
description: "Question routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
@@ -56,31 +66,3 @@ export const QuestionApi = HttpApi.make("question")
|
||||
description: "Effect HttpApi surface for instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const questionHandlers = HttpApiBuilder.group(QuestionApi, "question", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Question.Service
|
||||
|
||||
const list = Effect.fn("QuestionHttpApi.list")(function* () {
|
||||
return yield* svc.list()
|
||||
})
|
||||
|
||||
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
|
||||
params: { requestID: QuestionID }
|
||||
payload: Question.Reply
|
||||
}) {
|
||||
yield* svc.reply({
|
||||
requestID: ctx.params.requestID,
|
||||
answers: ctx.payload.answers,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) {
|
||||
yield* svc.reject(ctx.params.requestID)
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("reply", reply).handle("reject", reject)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,428 @@
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { SessionRevert } from "@/session/revert"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { MessageID, PartID, SessionID } from "@/session/schema"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { Schema, SchemaGetter, Struct } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
import { InstanceContextMiddleware } from "../instance-context"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/session"
|
||||
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
export const ListQuery = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
scope: Schema.optional(Schema.Literals(["project"])),
|
||||
path: Schema.optional(Schema.String),
|
||||
roots: Schema.optional(QueryBoolean),
|
||||
start: Schema.optional(Schema.NumberFromString),
|
||||
search: Schema.optional(Schema.String),
|
||||
limit: Schema.optional(Schema.NumberFromString),
|
||||
})
|
||||
export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
|
||||
export const MessagesQuery = Schema.Struct({
|
||||
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
|
||||
before: Schema.optional(Schema.String),
|
||||
})
|
||||
export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
|
||||
export const UpdatePayload = Schema.Struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
permission: Schema.optional(Permission.Ruleset),
|
||||
time: Schema.optional(
|
||||
Schema.Struct({
|
||||
archived: Schema.optional(NonNegativeInt),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"]))
|
||||
export const InitPayload = Schema.Struct({
|
||||
modelID: ModelID,
|
||||
providerID: ProviderID,
|
||||
messageID: MessageID,
|
||||
})
|
||||
export const SummarizePayload = Schema.Struct({
|
||||
providerID: ProviderID,
|
||||
modelID: ModelID,
|
||||
auto: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))
|
||||
export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"]))
|
||||
export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"]))
|
||||
export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"]))
|
||||
export const PermissionResponsePayload = Schema.Struct({
|
||||
response: Permission.Reply,
|
||||
})
|
||||
|
||||
export const SessionPaths = {
|
||||
list: root,
|
||||
status: `${root}/status`,
|
||||
get: `${root}/:sessionID`,
|
||||
children: `${root}/:sessionID/children`,
|
||||
todo: `${root}/:sessionID/todo`,
|
||||
diff: `${root}/:sessionID/diff`,
|
||||
messages: `${root}/:sessionID/message`,
|
||||
message: `${root}/:sessionID/message/:messageID`,
|
||||
create: root,
|
||||
remove: `${root}/:sessionID`,
|
||||
update: `${root}/:sessionID`,
|
||||
fork: `${root}/:sessionID/fork`,
|
||||
abort: `${root}/:sessionID/abort`,
|
||||
share: `${root}/:sessionID/share`,
|
||||
init: `${root}/:sessionID/init`,
|
||||
summarize: `${root}/:sessionID/summarize`,
|
||||
prompt: `${root}/:sessionID/message`,
|
||||
promptAsync: `${root}/:sessionID/prompt_async`,
|
||||
command: `${root}/:sessionID/command`,
|
||||
shell: `${root}/:sessionID/shell`,
|
||||
revert: `${root}/:sessionID/revert`,
|
||||
unrevert: `${root}/:sessionID/unrevert`,
|
||||
permissions: `${root}/:sessionID/permissions/:permissionID`,
|
||||
deleteMessage: `${root}/:sessionID/message/:messageID`,
|
||||
deletePart: `${root}/:sessionID/message/:messageID/part/:partID`,
|
||||
updatePart: `${root}/:sessionID/message/:messageID/part/:partID`,
|
||||
} as const
|
||||
|
||||
export const SessionApi = HttpApi.make("session")
|
||||
.add(
|
||||
HttpApiGroup.make("session")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", SessionPaths.list, {
|
||||
query: ListQuery,
|
||||
success: described(Schema.Array(Session.Info), "List of sessions"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.list",
|
||||
summary: "List sessions",
|
||||
description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", SessionPaths.status, {
|
||||
success: described(StatusMap, "Get session status"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.status",
|
||||
summary: "Get session status",
|
||||
description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("get", SessionPaths.get, {
|
||||
params: { sessionID: SessionID },
|
||||
success: described(Session.Info, "Get session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.get",
|
||||
summary: "Get session",
|
||||
description: "Retrieve detailed information about a specific OpenCode session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("children", SessionPaths.children, {
|
||||
params: { sessionID: SessionID },
|
||||
success: described(Schema.Array(Session.Info), "List of children"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.children",
|
||||
summary: "Get session children",
|
||||
description: "Retrieve all child sessions that were forked from the specified parent session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("todo", SessionPaths.todo, {
|
||||
params: { sessionID: SessionID },
|
||||
success: described(Schema.Array(Todo.Info), "Todo list"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.todo",
|
||||
summary: "Get session todos",
|
||||
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("diff", SessionPaths.diff, {
|
||||
params: { sessionID: SessionID },
|
||||
query: DiffQuery,
|
||||
success: described(Schema.Array(Snapshot.FileDiff), "Successfully retrieved diff"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.diff",
|
||||
summary: "Get message diff",
|
||||
description: "Get the file changes (diff) that resulted from a specific user message in the session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("messages", SessionPaths.messages, {
|
||||
params: { sessionID: SessionID },
|
||||
query: MessagesQuery,
|
||||
success: described(Schema.Array(MessageV2.WithParts), "List of messages"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.messages",
|
||||
summary: "Get session messages",
|
||||
description: "Retrieve all messages in a session, including user prompts and AI responses.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("message", SessionPaths.message, {
|
||||
params: { sessionID: SessionID, messageID: MessageID },
|
||||
success: described(MessageV2.WithParts, "Message"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.message",
|
||||
summary: "Get message",
|
||||
description: "Retrieve a specific message from a session by its message ID.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", SessionPaths.create, {
|
||||
payload: [HttpApiSchema.NoContent, Session.CreateInput],
|
||||
success: described(Session.Info, "Successfully created session"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.create",
|
||||
summary: "Create session",
|
||||
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
|
||||
params: { sessionID: SessionID },
|
||||
success: described(Schema.Boolean, "Successfully deleted session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.delete",
|
||||
summary: "Delete session",
|
||||
description: "Delete a session and permanently remove all associated data, including messages and history.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.patch("update", SessionPaths.update, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: UpdatePayload,
|
||||
success: described(Session.Info, "Successfully updated session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.update",
|
||||
summary: "Update session",
|
||||
description: "Update properties of an existing session, such as title or other metadata.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("fork", SessionPaths.fork, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: ForkPayload,
|
||||
success: described(Session.Info, "200"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.fork",
|
||||
summary: "Fork session",
|
||||
description: "Create a new session by forking an existing session at a specific message point.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("abort", SessionPaths.abort, {
|
||||
params: { sessionID: SessionID },
|
||||
success: described(Schema.Boolean, "Aborted session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.abort",
|
||||
summary: "Abort session",
|
||||
description: "Abort an active session and stop any ongoing AI processing or command execution.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("init", SessionPaths.init, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: InitPayload,
|
||||
success: described(Schema.Boolean, "200"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.init",
|
||||
summary: "Initialize session",
|
||||
description:
|
||||
"Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("share", SessionPaths.share, {
|
||||
params: { sessionID: SessionID },
|
||||
success: described(Session.Info, "Successfully shared session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.share",
|
||||
summary: "Share session",
|
||||
description: "Create a shareable link for a session, allowing others to view the conversation.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
|
||||
params: { sessionID: SessionID },
|
||||
success: described(Session.Info, "Successfully unshared session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.unshare",
|
||||
summary: "Unshare session",
|
||||
description: "Remove the shareable link for a session, making it private again.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: SummarizePayload,
|
||||
success: described(Schema.Boolean, "Summarized session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.summarize",
|
||||
summary: "Summarize session",
|
||||
description: "Generate a concise summary of the session using AI compaction to preserve key information.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: PromptPayload,
|
||||
success: described(MessageV2.WithParts, "Created message"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.prompt",
|
||||
summary: "Send message",
|
||||
description: "Create and send a new message to a session, streaming the AI response.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: PromptPayload,
|
||||
success: described(HttpApiSchema.NoContent, "Prompt accepted"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.prompt_async",
|
||||
summary: "Send async message",
|
||||
description:
|
||||
"Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("command", SessionPaths.command, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: CommandPayload,
|
||||
success: described(MessageV2.WithParts, "Created message"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.command",
|
||||
summary: "Send command",
|
||||
description: "Send a new command to a session for execution by the AI assistant.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("shell", SessionPaths.shell, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: ShellPayload,
|
||||
success: described(MessageV2.WithParts, "Created message"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.shell",
|
||||
summary: "Run shell command",
|
||||
description: "Execute a shell command within the session context and return the AI's response.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("revert", SessionPaths.revert, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: RevertPayload,
|
||||
success: described(Session.Info, "Updated session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.revert",
|
||||
summary: "Revert message",
|
||||
description:
|
||||
"Revert a specific message in a session, undoing its effects and restoring the previous state.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
|
||||
params: { sessionID: SessionID },
|
||||
success: described(Session.Info, "Updated session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.unrevert",
|
||||
summary: "Restore reverted messages",
|
||||
description: "Restore all previously reverted messages in a session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
|
||||
params: { sessionID: SessionID, permissionID: PermissionID },
|
||||
payload: PermissionResponsePayload,
|
||||
success: described(Schema.Boolean, "Permission processed successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "permission.respond",
|
||||
summary: "Respond to permission",
|
||||
description: "Approve or deny a permission request from the AI assistant.",
|
||||
deprecated: true,
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
|
||||
params: { sessionID: SessionID, messageID: MessageID },
|
||||
success: described(Schema.Boolean, "Successfully deleted message"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.deleteMessage",
|
||||
summary: "Delete message",
|
||||
description:
|
||||
"Permanently delete a specific message and all of its parts from a session without reverting file changes.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
|
||||
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
|
||||
success: described(Schema.Boolean, "Successfully deleted part"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "part.delete",
|
||||
description: "Delete a part from a message.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
|
||||
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
|
||||
payload: MessageV2.Part,
|
||||
success: described(MessageV2.Part, "Successfully updated part"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "part.update",
|
||||
description: "Update a part in a message.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "session",
|
||||
description: "Experimental HttpApi session routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
import { InstanceContextMiddleware } from "../instance-context"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/sync"
|
||||
export const ReplayEvent = Schema.Struct({
|
||||
id: Schema.String,
|
||||
aggregateID: Schema.String,
|
||||
seq: NonNegativeInt,
|
||||
type: Schema.String,
|
||||
data: Schema.Record(Schema.String, Schema.Unknown),
|
||||
})
|
||||
export const ReplayPayload = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
events: Schema.NonEmptyArray(ReplayEvent),
|
||||
})
|
||||
export const ReplayResponse = Schema.Struct({
|
||||
sessionID: Schema.String,
|
||||
})
|
||||
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
|
||||
export const HistoryEvent = Schema.Struct({
|
||||
id: Schema.String,
|
||||
aggregate_id: Schema.String,
|
||||
seq: NonNegativeInt,
|
||||
type: Schema.String,
|
||||
data: Schema.Record(Schema.String, Schema.Unknown),
|
||||
})
|
||||
|
||||
export const SyncPaths = {
|
||||
start: `${root}/start`,
|
||||
replay: `${root}/replay`,
|
||||
history: `${root}/history`,
|
||||
} as const
|
||||
|
||||
export const SyncApi = HttpApi.make("sync")
|
||||
.add(
|
||||
HttpApiGroup.make("sync")
|
||||
.add(
|
||||
HttpApiEndpoint.post("start", SyncPaths.start, {
|
||||
success: described(Schema.Boolean, "Workspace sync started"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "sync.start",
|
||||
summary: "Start workspace sync",
|
||||
description: "Start sync loops for workspaces in the current project that have active sessions.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("replay", SyncPaths.replay, {
|
||||
payload: ReplayPayload,
|
||||
success: described(ReplayResponse, "Replayed sync events"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "sync.replay",
|
||||
summary: "Replay sync events",
|
||||
description: "Validate and replay a complete sync event history.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("history", SyncPaths.history, {
|
||||
payload: HistoryPayload,
|
||||
success: described(Schema.Array(HistoryEvent), "Sync events"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "sync.history.list",
|
||||
summary: "List sync events",
|
||||
description:
|
||||
"List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "sync",
|
||||
description: "Experimental HttpApi sync routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
import { InstanceContextMiddleware } from "../instance-context"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/tui"
|
||||
export const CommandPayload = Schema.Struct({ command: Schema.String })
|
||||
export const TuiRequestPayload = Schema.Struct({
|
||||
path: Schema.String,
|
||||
body: Schema.Unknown,
|
||||
})
|
||||
const EventTuiPromptAppend = Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }).annotate({ identifier: "EventTuiPromptAppend" })
|
||||
const EventTuiCommandExecute = Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }).annotate({ identifier: "EventTuiCommandExecute" })
|
||||
const EventTuiToastShow = Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }).annotate({ identifier: "EventTuiToastShow" })
|
||||
const EventTuiSessionSelect = Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }).annotate({ identifier: "EventTuiSessionSelect" })
|
||||
export const TuiPublishPayload = Schema.Union([EventTuiPromptAppend, EventTuiCommandExecute, EventTuiToastShow, EventTuiSessionSelect])
|
||||
|
||||
export const TuiPaths = {
|
||||
appendPrompt: `${root}/append-prompt`,
|
||||
openHelp: `${root}/open-help`,
|
||||
openSessions: `${root}/open-sessions`,
|
||||
openThemes: `${root}/open-themes`,
|
||||
openModels: `${root}/open-models`,
|
||||
submitPrompt: `${root}/submit-prompt`,
|
||||
clearPrompt: `${root}/clear-prompt`,
|
||||
executeCommand: `${root}/execute-command`,
|
||||
showToast: `${root}/show-toast`,
|
||||
publish: `${root}/publish`,
|
||||
selectSession: `${root}/select-session`,
|
||||
controlNext: `${root}/control/next`,
|
||||
controlResponse: `${root}/control/response`,
|
||||
} as const
|
||||
|
||||
export const TuiApi = HttpApi.make("tui")
|
||||
.add(
|
||||
HttpApiGroup.make("tui")
|
||||
.add(
|
||||
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
|
||||
payload: TuiEvent.PromptAppend.properties,
|
||||
success: described(Schema.Boolean, "Prompt processed successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.appendPrompt",
|
||||
summary: "Append TUI prompt",
|
||||
description: "Append prompt to the TUI.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: described(Schema.Boolean, "Help dialog opened successfully") }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.openHelp",
|
||||
summary: "Open help dialog",
|
||||
description: "Open the help dialog in the TUI to display user assistance information.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: described(Schema.Boolean, "Session dialog opened successfully") }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.openSessions",
|
||||
summary: "Open sessions dialog",
|
||||
description: "Open the session dialog.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: described(Schema.Boolean, "Theme dialog opened successfully") }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.openThemes",
|
||||
summary: "Open themes dialog",
|
||||
description: "Open the theme dialog.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: described(Schema.Boolean, "Model dialog opened successfully") }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.openModels",
|
||||
summary: "Open models dialog",
|
||||
description: "Open the model dialog.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: described(Schema.Boolean, "Prompt submitted successfully") }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.submitPrompt",
|
||||
summary: "Submit TUI prompt",
|
||||
description: "Submit the prompt.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: described(Schema.Boolean, "Prompt cleared successfully") }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.clearPrompt",
|
||||
summary: "Clear TUI prompt",
|
||||
description: "Clear the prompt.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
|
||||
payload: CommandPayload,
|
||||
success: described(Schema.Boolean, "Command executed successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.executeCommand",
|
||||
summary: "Execute TUI command",
|
||||
description: "Execute a TUI command.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
|
||||
payload: TuiEvent.ToastShow.properties,
|
||||
success: described(Schema.Boolean, "Toast notification shown successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.showToast",
|
||||
summary: "Show TUI toast",
|
||||
description: "Show a toast notification in the TUI.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("publish", TuiPaths.publish, {
|
||||
payload: TuiPublishPayload,
|
||||
success: described(Schema.Boolean, "Event published successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.publish",
|
||||
summary: "Publish TUI event",
|
||||
description: "Publish a TUI event.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
|
||||
payload: TuiEvent.SessionSelect.properties,
|
||||
success: described(Schema.Boolean, "Session selected successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.selectSession",
|
||||
summary: "Select session",
|
||||
description: "Navigate the TUI to display the specified session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: described(TuiRequestPayload, "Next TUI request") }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.control.next",
|
||||
summary: "Get next TUI request",
|
||||
description: "Retrieve the next TUI request from the queue for processing.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, {
|
||||
payload: Schema.Unknown,
|
||||
success: described(Schema.Boolean, "Response submitted successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.control.response",
|
||||
summary: "Submit TUI response",
|
||||
description: "Submit a response to the TUI request queue to complete a pending request.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." }))
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../auth"
|
||||
import { InstanceContextMiddleware } from "../instance-context"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/experimental/workspace"
|
||||
export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
|
||||
export const SessionRestorePayload = Schema.Struct(
|
||||
Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]),
|
||||
)
|
||||
export const SessionRestoreResponse = Schema.Struct({
|
||||
total: NonNegativeInt,
|
||||
})
|
||||
|
||||
export const WorkspacePaths = {
|
||||
adaptors: `${root}/adaptor`,
|
||||
list: root,
|
||||
status: `${root}/status`,
|
||||
remove: `${root}/:id`,
|
||||
sessionRestore: `${root}/:id/session-restore`,
|
||||
} as const
|
||||
|
||||
export const WorkspaceApi = HttpApi.make("workspace")
|
||||
.add(
|
||||
HttpApiGroup.make("workspace")
|
||||
.add(
|
||||
HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, {
|
||||
success: described(Schema.Array(WorkspaceAdaptorEntry), "Workspace adaptors"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.adaptor.list",
|
||||
summary: "List workspace adaptors",
|
||||
description: "List all available workspace adaptors for the current project.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("list", WorkspacePaths.list, {
|
||||
success: described(Schema.Array(Workspace.Info), "Workspaces"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.list",
|
||||
summary: "List workspaces",
|
||||
description: "List all workspaces.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", WorkspacePaths.list, {
|
||||
payload: CreatePayload,
|
||||
success: described(Workspace.Info, "Workspace created"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.create",
|
||||
summary: "Create workspace",
|
||||
description: "Create a workspace for the current project.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", WorkspacePaths.status, {
|
||||
success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.status",
|
||||
summary: "Workspace status",
|
||||
description: "Get connection status for workspaces in the current project.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", WorkspacePaths.remove, {
|
||||
params: { id: Workspace.Info.fields.id },
|
||||
success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.remove",
|
||||
summary: "Remove workspace",
|
||||
description: "Remove an existing workspace.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
|
||||
params: { id: Workspace.Info.fields.id },
|
||||
payload: SessionRestorePayload,
|
||||
success: described(SessionRestoreResponse, "Session replay started"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.sessionRestore",
|
||||
summary: "Restore session into workspace",
|
||||
description: "Replay a session's sync events into the target workspace in batches.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "workspace", description: "Experimental HttpApi workspace routes." }))
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Config } from "@/config/config"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { markInstanceForDisposal } from "../lifecycle"
|
||||
|
||||
export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const providerSvc = yield* Provider.Service
|
||||
const configSvc = yield* Config.Service
|
||||
|
||||
const get = Effect.fn("ConfigHttpApi.get")(function* () {
|
||||
return yield* configSvc.get()
|
||||
})
|
||||
|
||||
const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
|
||||
yield* configSvc.update(ctx.payload, { dispose: false })
|
||||
yield* markInstanceForDisposal(yield* InstanceState.context)
|
||||
return ctx.payload
|
||||
})
|
||||
|
||||
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
|
||||
const providers = yield* providerSvc.list()
|
||||
return {
|
||||
providers: Object.values(providers),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
}
|
||||
})
|
||||
|
||||
return handlers.handle("get", get).handle("update", update).handle("providers", providers)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Auth } from "@/auth"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { RootHttpApi } from "../api"
|
||||
import { LogInput } from "../groups/control"
|
||||
|
||||
export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
|
||||
const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: {
|
||||
params: { providerID: ProviderID }
|
||||
payload: Auth.Info
|
||||
}) {
|
||||
yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
|
||||
return true
|
||||
})
|
||||
|
||||
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) {
|
||||
yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
|
||||
return true
|
||||
})
|
||||
|
||||
const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) {
|
||||
const logger = Log.create({ service: ctx.payload.service })
|
||||
logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra)
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Account } from "@/account/account"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Project } from "@/project/project"
|
||||
import { Session } from "@/session/session"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import * as EffectZod from "@/util/effect-zod"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Effect, Option } from "effect"
|
||||
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery } from "../groups/experimental"
|
||||
|
||||
export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "experimental", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const account = yield* Account.Service
|
||||
const agents = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const project = yield* Project.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const worktreeSvc = yield* Worktree.Service
|
||||
|
||||
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
|
||||
const [state, groups] = yield* Effect.all(
|
||||
[config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)],
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
)
|
||||
return {
|
||||
consoleManagedProviders: state.consoleManagedProviders,
|
||||
...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}),
|
||||
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
||||
}
|
||||
})
|
||||
|
||||
const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () {
|
||||
const [groups, active] = yield* Effect.all(
|
||||
[account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)],
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
)
|
||||
const info = Option.getOrUndefined(active)
|
||||
return {
|
||||
orgs: groups.flatMap((group) =>
|
||||
group.orgs.map((org) => ({
|
||||
accountID: group.account.id,
|
||||
accountEmail: group.account.email,
|
||||
accountUrl: group.account.url,
|
||||
orgID: org.id,
|
||||
orgName: org.name,
|
||||
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
|
||||
})),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: {
|
||||
payload: typeof ConsoleSwitchPayload.Type
|
||||
}) {
|
||||
yield* account
|
||||
.use(ctx.payload.accountID, Option.some(ctx.payload.orgID))
|
||||
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
|
||||
return true
|
||||
})
|
||||
|
||||
const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) {
|
||||
const list = yield* registry.tools({
|
||||
providerID: ctx.query.provider,
|
||||
modelID: ctx.query.model,
|
||||
agent: yield* agents.get(yield* agents.defaultAgent()),
|
||||
})
|
||||
return list.map((item) => ({
|
||||
id: item.id,
|
||||
description: item.description,
|
||||
parameters: EffectZod.toJsonSchema(item.parameters),
|
||||
}))
|
||||
})
|
||||
|
||||
const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () {
|
||||
return yield* registry.ids()
|
||||
})
|
||||
|
||||
const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
return yield* project.sandboxes(ctx.project.id)
|
||||
})
|
||||
|
||||
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
|
||||
payload: Worktree.CreateInput | undefined
|
||||
}) {
|
||||
return yield* worktreeSvc.create(ctx.payload)
|
||||
})
|
||||
|
||||
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
|
||||
payload: Worktree.RemoveInput
|
||||
}) {
|
||||
const ctx = yield* InstanceState.context
|
||||
yield* worktreeSvc.remove(input.payload)
|
||||
yield* project.removeSandbox(ctx.project.id, input.payload.directory)
|
||||
return true
|
||||
})
|
||||
|
||||
const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: {
|
||||
payload: Worktree.ResetInput
|
||||
}) {
|
||||
yield* worktreeSvc.reset(ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
|
||||
const limit = ctx.query.limit ?? 100
|
||||
const sessions = Array.from(
|
||||
Session.listGlobal({
|
||||
directory: ctx.query.directory,
|
||||
roots: ctx.query.roots,
|
||||
start: ctx.query.start,
|
||||
cursor: ctx.query.cursor,
|
||||
search: ctx.query.search,
|
||||
limit: limit + 1,
|
||||
archived: ctx.query.archived,
|
||||
}),
|
||||
)
|
||||
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
|
||||
return HttpServerResponse.jsonUnsafe(list, {
|
||||
headers:
|
||||
sessions.length > limit && list.length > 0
|
||||
? { "x-next-cursor": String(list[list.length - 1].time.updated) }
|
||||
: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
|
||||
return yield* mcp.resources()
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("console", getConsole)
|
||||
.handle("consoleOrgs", listConsoleOrgs)
|
||||
.handle("consoleSwitch", switchConsole)
|
||||
.handle("tool", tool)
|
||||
.handle("toolIDs", toolIDs)
|
||||
.handle("worktree", worktree)
|
||||
.handle("worktreeCreate", worktreeCreate)
|
||||
.handle("worktreeRemove", worktreeRemove)
|
||||
.handle("worktreeReset", worktreeReset)
|
||||
.handle("session", session)
|
||||
.handle("resource", resource)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { File } from "@/file"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
|
||||
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* File.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
|
||||
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
|
||||
return (yield* ripgrep
|
||||
.search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 })
|
||||
.pipe(Effect.orDie)).items
|
||||
})
|
||||
|
||||
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
|
||||
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
|
||||
}) {
|
||||
return yield* svc.search({
|
||||
query: ctx.query.query,
|
||||
limit: ctx.query.limit ?? 10,
|
||||
dirs: ctx.query.dirs !== "false",
|
||||
type: ctx.query.type,
|
||||
})
|
||||
})
|
||||
|
||||
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
|
||||
return []
|
||||
})
|
||||
|
||||
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
|
||||
return yield* svc.list(ctx.query.path)
|
||||
})
|
||||
|
||||
const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
|
||||
return yield* svc.read(ctx.query.path)
|
||||
})
|
||||
|
||||
const status = Effect.fn("FileHttpApi.status")(function* () {
|
||||
return yield* svc.status()
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("findText", findText)
|
||||
.handle("findFile", findFile)
|
||||
.handle("findSymbol", findSymbol)
|
||||
.handle("list", list)
|
||||
.handle("content", content)
|
||||
.handle("status", status)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Config } from "@/config/config"
|
||||
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
|
||||
import { Installation } from "@/installation"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect, Queue, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { RootHttpApi } from "../api"
|
||||
import { GlobalUpgradeInput } from "../groups/global"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
function eventData(data: unknown): Sse.Event {
|
||||
return {
|
||||
_tag: "Event",
|
||||
event: "message",
|
||||
id: undefined,
|
||||
data: JSON.stringify(data),
|
||||
}
|
||||
}
|
||||
|
||||
function parseBody(body: string) {
|
||||
try {
|
||||
return JSON.parse(body || "{}") as unknown
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function eventResponse() {
|
||||
log.info("global event connected")
|
||||
const events = Stream.callback<GlobalBusEvent>((queue) => {
|
||||
const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event)
|
||||
return Effect.acquireRelease(
|
||||
Effect.sync(() => GlobalBus.on("event", handler)),
|
||||
() => Effect.sync(() => GlobalBus.off("event", handler)),
|
||||
)
|
||||
})
|
||||
const heartbeat = Stream.tick("10 seconds").pipe(
|
||||
Stream.drop(1),
|
||||
Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })),
|
||||
)
|
||||
|
||||
return HttpServerResponse.stream(
|
||||
Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe(
|
||||
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
|
||||
Stream.map(eventData),
|
||||
Stream.pipeThroughChannel(Sse.encode()),
|
||||
Stream.encodeText,
|
||||
Stream.ensuring(Effect.sync(() => log.info("global event disconnected"))),
|
||||
),
|
||||
{
|
||||
contentType: "text/event-stream",
|
||||
headers: {
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const installation = yield* Installation.Service
|
||||
|
||||
const health = Effect.fn("GlobalHttpApi.health")(function* () {
|
||||
return { healthy: true as const, version: InstallationVersion }
|
||||
})
|
||||
|
||||
const event = Effect.fn("GlobalHttpApi.event")(function* () {
|
||||
return eventResponse()
|
||||
})
|
||||
|
||||
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
|
||||
return yield* config.getGlobal()
|
||||
})
|
||||
|
||||
const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
|
||||
return yield* config.updateGlobal(ctx.payload)
|
||||
})
|
||||
|
||||
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
|
||||
yield* Effect.promise(() => Instance.disposeAll())
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: { type: "global.disposed", properties: {} },
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) {
|
||||
const method = yield* installation.method()
|
||||
if (method === "unknown") {
|
||||
return {
|
||||
status: 400,
|
||||
body: { success: false as const, error: "Unknown installation method" },
|
||||
}
|
||||
}
|
||||
const target = ctx.payload.target || (yield* installation.latest(method))
|
||||
const result = yield* installation.upgrade(method, target).pipe(
|
||||
Effect.as({ status: 200, body: { success: true as const, version: target } }),
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
status: 500,
|
||||
body: {
|
||||
success: false as const,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!result.body.success) return result
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Installation.Event.Updated.type,
|
||||
properties: { version: target },
|
||||
},
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: {
|
||||
request: HttpServerRequest.HttpServerRequest
|
||||
}) {
|
||||
const body = yield* Effect.orDie(ctx.request.text)
|
||||
const json = parseBody(body)
|
||||
if (json === undefined) {
|
||||
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
|
||||
}
|
||||
const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe(
|
||||
Effect.map((payload) => ({ valid: true as const, payload })),
|
||||
Effect.catch(() => Effect.succeed({ valid: false as const })),
|
||||
)
|
||||
if (!payload.valid) {
|
||||
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
|
||||
}
|
||||
const result = yield* upgrade({ payload: payload.payload })
|
||||
return HttpServerResponse.jsonUnsafe(result.body, { status: result.status })
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("health", health)
|
||||
.handleRaw("event", event)
|
||||
.handle("configGet", configGet)
|
||||
.handle("configUpdate", configUpdate)
|
||||
.handle("dispose", dispose)
|
||||
.handleRaw("upgrade", upgradeRaw)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Command } from "@/command"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Format } from "@/format"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Skill } from "@/skill"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { markInstanceForDisposal } from "../lifecycle"
|
||||
|
||||
export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const command = yield* Command.Service
|
||||
const format = yield* Format.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const skill = yield* Skill.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
|
||||
const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () {
|
||||
yield* markInstanceForDisposal(yield* InstanceState.context)
|
||||
return true
|
||||
})
|
||||
|
||||
const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
return {
|
||||
home: Global.Path.home,
|
||||
state: Global.Path.state,
|
||||
config: Global.Path.config,
|
||||
worktree: ctx.worktree,
|
||||
directory: ctx.directory,
|
||||
}
|
||||
})
|
||||
|
||||
const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () {
|
||||
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
|
||||
return { branch, default_branch }
|
||||
})
|
||||
|
||||
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
|
||||
return yield* vcs.diff(ctx.query.mode)
|
||||
})
|
||||
|
||||
const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
|
||||
return yield* command.list()
|
||||
})
|
||||
|
||||
const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () {
|
||||
return yield* agent.list()
|
||||
})
|
||||
|
||||
const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () {
|
||||
return yield* skill.all()
|
||||
})
|
||||
|
||||
const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () {
|
||||
return yield* lsp.status()
|
||||
})
|
||||
|
||||
const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () {
|
||||
return yield* format.status()
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("dispose", dispose)
|
||||
.handle("path", getPath)
|
||||
.handle("vcs", getVcs)
|
||||
.handle("vcsDiff", getVcsDiff)
|
||||
.handle("command", getCommand)
|
||||
.handle("agent", getAgent)
|
||||
.handle("skill", getSkill)
|
||||
.handle("lsp", getLsp)
|
||||
.handle("formatter", getFormatter)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
import { MCP } from "@/mcp"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp"
|
||||
|
||||
export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
|
||||
const status = Effect.fn("McpHttpApi.status")(function* () {
|
||||
return yield* mcp.status()
|
||||
})
|
||||
|
||||
const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) {
|
||||
const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status
|
||||
return yield* Schema.decodeUnknownEffect(StatusMap)(
|
||||
"status" in result ? { [ctx.payload.name]: result } : result,
|
||||
).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
})
|
||||
|
||||
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
|
||||
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
|
||||
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
|
||||
}
|
||||
return yield* mcp.startAuth(ctx.params.name)
|
||||
})
|
||||
|
||||
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
|
||||
params: { name: string }
|
||||
payload: typeof AuthCallbackPayload.Type
|
||||
}) {
|
||||
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
|
||||
})
|
||||
|
||||
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
|
||||
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
|
||||
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
|
||||
}
|
||||
return yield* mcp.authenticate(ctx.params.name)
|
||||
})
|
||||
|
||||
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
|
||||
yield* mcp.removeAuth(ctx.params.name)
|
||||
return { success: true as const }
|
||||
})
|
||||
|
||||
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
|
||||
yield* mcp.connect(ctx.params.name)
|
||||
return true
|
||||
})
|
||||
|
||||
const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
|
||||
yield* mcp.disconnect(ctx.params.name)
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("status", status)
|
||||
.handle("add", add)
|
||||
.handle("authStart", authStart)
|
||||
.handle("authCallback", authCallback)
|
||||
.handle("authAuthenticate", authAuthenticate)
|
||||
.handle("authRemove", authRemove)
|
||||
.handle("connect", connect)
|
||||
.handle("disconnect", disconnect)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
|
||||
export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permission", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Permission.Service
|
||||
|
||||
const list = Effect.fn("PermissionHttpApi.list")(function* () {
|
||||
return yield* svc.list()
|
||||
})
|
||||
|
||||
const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
|
||||
params: { requestID: PermissionID }
|
||||
payload: Permission.ReplyBody
|
||||
}) {
|
||||
yield* svc.reply({
|
||||
requestID: ctx.params.requestID,
|
||||
reply: ctx.payload.reply,
|
||||
message: ctx.payload.message,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("reply", reply)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { Project } from "@/project/project"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { markInstanceForReload } from "../lifecycle"
|
||||
|
||||
export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Project.Service
|
||||
|
||||
const list = Effect.fn("ProjectHttpApi.list")(function* () {
|
||||
return yield* svc.list()
|
||||
})
|
||||
|
||||
const current = Effect.fn("ProjectHttpApi.current")(function* () {
|
||||
return (yield* InstanceState.context).project
|
||||
})
|
||||
|
||||
const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project })
|
||||
if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree)
|
||||
return next
|
||||
yield* markInstanceForReload(ctx, {
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.directory,
|
||||
project: next,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
})
|
||||
return next
|
||||
})
|
||||
|
||||
const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: {
|
||||
params: { projectID: ProjectID }
|
||||
payload: Project.UpdatePayload
|
||||
}) {
|
||||
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID })
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { Config } from "@/config/config"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { mapValues } from "remeda"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
|
||||
export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const provider = yield* Provider.Service
|
||||
const svc = yield* ProviderAuth.Service
|
||||
|
||||
const list = Effect.fn("ProviderHttpApi.list")(function* () {
|
||||
const config = yield* cfg.get()
|
||||
const all = yield* Effect.promise(() => ModelsDev.get())
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
const filtered: Record<string, (typeof all)[string]> = {}
|
||||
for (const [key, value] of Object.entries(all)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value
|
||||
}
|
||||
const connected = yield* provider.list()
|
||||
const providers = Object.assign(
|
||||
mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)),
|
||||
connected,
|
||||
)
|
||||
return {
|
||||
all: Object.values(providers),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
connected: Object.keys(connected),
|
||||
}
|
||||
})
|
||||
|
||||
const auth = Effect.fn("ProviderHttpApi.auth")(function* () {
|
||||
return yield* svc.methods()
|
||||
})
|
||||
|
||||
const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: {
|
||||
params: { providerID: ProviderID }
|
||||
payload: ProviderAuth.AuthorizeInput
|
||||
}) {
|
||||
return yield* svc
|
||||
.authorize({
|
||||
providerID: ctx.params.providerID,
|
||||
method: ctx.payload.method,
|
||||
inputs: ctx.payload.inputs,
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
|
||||
})
|
||||
|
||||
const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: {
|
||||
params: { providerID: ProviderID }
|
||||
request: HttpServerRequest.HttpServerRequest
|
||||
}) {
|
||||
const body = yield* Effect.orDie(ctx.request.text)
|
||||
const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe(
|
||||
Effect.mapError(() => new HttpApiError.BadRequest({})),
|
||||
)
|
||||
const result = yield* authorize({ params: ctx.params, payload })
|
||||
if (result === undefined) return HttpServerResponse.empty({ status: 200 })
|
||||
return HttpServerResponse.jsonUnsafe(result)
|
||||
})
|
||||
|
||||
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
|
||||
params: { providerID: ProviderID }
|
||||
payload: ProviderAuth.CallbackInput
|
||||
}) {
|
||||
yield* svc
|
||||
.callback({
|
||||
providerID: ctx.params.providerID,
|
||||
method: ctx.payload.method,
|
||||
code: ctx.payload.code,
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("list", list)
|
||||
.handle("auth", auth)
|
||||
.handleRaw("authorize", authorizeRaw)
|
||||
.handle("callback", callback)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
|
||||
|
||||
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
|
||||
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
|
||||
return yield* Effect.promise(() => Shell.list())
|
||||
})
|
||||
|
||||
const list = Effect.fn("PtyHttpApi.list")(function* () {
|
||||
return yield* pty.list()
|
||||
})
|
||||
|
||||
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
|
||||
const bridge = yield* EffectBridge.make()
|
||||
return yield* Effect.promise(() =>
|
||||
bridge.promise(
|
||||
pty.create({
|
||||
...ctx.payload,
|
||||
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
||||
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
const info = yield* pty.get(ctx.params.ptyID)
|
||||
if (!info) return yield* new HttpApiError.NotFound({})
|
||||
return info
|
||||
})
|
||||
|
||||
const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
|
||||
params: { ptyID: PtyID }
|
||||
payload: typeof Pty.UpdateInput.Type
|
||||
}) {
|
||||
const info = yield* pty.update(ctx.params.ptyID, {
|
||||
...ctx.payload,
|
||||
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
|
||||
})
|
||||
if (!info) return yield* new HttpApiError.NotFound({})
|
||||
return info
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
yield* pty.remove(ctx.params.ptyID)
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("shells", shells)
|
||||
.handle("list", list)
|
||||
.handle("create", create)
|
||||
.handle("get", get)
|
||||
.handle("update", update)
|
||||
.handle("remove", remove)
|
||||
}),
|
||||
)
|
||||
|
||||
export const ptyConnectRoute = HttpRouter.add(
|
||||
"GET",
|
||||
PtyPaths.connect,
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const params = yield* HttpRouter.schemaPathParams(Params)
|
||||
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
|
||||
|
||||
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
|
||||
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
|
||||
const cursor =
|
||||
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
|
||||
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
|
||||
const write = yield* socket.writer
|
||||
let closed = false
|
||||
const adapter = {
|
||||
get readyState() {
|
||||
return closed ? 3 : 1
|
||||
},
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => {
|
||||
if (closed) return
|
||||
Effect.runFork(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)))
|
||||
},
|
||||
close: (code?: number, reason?: string) => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
|
||||
},
|
||||
}
|
||||
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
|
||||
if (!handler) return HttpServerResponse.empty()
|
||||
|
||||
yield* socket
|
||||
.runRaw((message) => {
|
||||
handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
closed = true
|
||||
handler.onClose()
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
return HttpServerResponse.empty()
|
||||
}).pipe(Effect.provide(Pty.defaultLayer)),
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Question } from "@/question"
|
||||
import { QuestionID } from "@/question/schema"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
|
||||
export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Question.Service
|
||||
|
||||
const list = Effect.fn("QuestionHttpApi.list")(function* () {
|
||||
return yield* svc.list()
|
||||
})
|
||||
|
||||
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
|
||||
params: { requestID: QuestionID }
|
||||
payload: Question.Reply
|
||||
}) {
|
||||
yield* svc.reply({
|
||||
requestID: ctx.params.requestID,
|
||||
answers: ctx.payload.answers,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) {
|
||||
yield* svc.reject(ctx.params.requestID)
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("reply", reply).handle("reject", reject)
|
||||
}),
|
||||
)
|
||||
+47
-440
@@ -6,7 +6,6 @@ import { Command } from "@/command"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
@@ -18,422 +17,27 @@ import { SessionStatus } from "@/session/status"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { MessageID, PartID, SessionID } from "@/session/schema"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Effect, Schema, SchemaGetter, Struct } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import {
|
||||
HttpApi,
|
||||
HttpApiBuilder,
|
||||
HttpApiEndpoint,
|
||||
HttpApiError,
|
||||
HttpApiGroup,
|
||||
HttpApiSchema,
|
||||
OpenApi,
|
||||
} from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { CommandPayload, DiffQuery, ForkPayload, InitPayload, ListQuery, MessagesQuery, PermissionResponsePayload, PromptPayload, RevertPayload, ShellPayload, SummarizePayload, UpdatePayload } from "../groups/session"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
const root = "/session"
|
||||
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
const ListQuery = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
scope: Schema.optional(Schema.Literals(["project"])),
|
||||
path: Schema.optional(Schema.String),
|
||||
roots: Schema.optional(QueryBoolean),
|
||||
start: Schema.optional(Schema.NumberFromString),
|
||||
search: Schema.optional(Schema.String),
|
||||
limit: Schema.optional(Schema.NumberFromString),
|
||||
})
|
||||
const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
|
||||
const MessagesQuery = Schema.Struct({
|
||||
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
|
||||
before: Schema.optional(Schema.String),
|
||||
})
|
||||
const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
|
||||
const UpdatePayload = Schema.Struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
permission: Schema.optional(Permission.Ruleset),
|
||||
time: Schema.optional(
|
||||
Schema.Struct({
|
||||
archived: Schema.optional(Schema.Number),
|
||||
}),
|
||||
),
|
||||
}).annotate({ identifier: "SessionUpdateInput" })
|
||||
const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({
|
||||
identifier: "SessionForkInput",
|
||||
})
|
||||
const InitPayload = Schema.Struct({
|
||||
modelID: ModelID,
|
||||
providerID: ProviderID,
|
||||
messageID: MessageID,
|
||||
}).annotate({ identifier: "SessionInitInput" })
|
||||
const SummarizePayload = Schema.Struct({
|
||||
providerID: ProviderID,
|
||||
modelID: ModelID,
|
||||
auto: Schema.optional(Schema.Boolean),
|
||||
}).annotate({ identifier: "SessionSummarizeInput" })
|
||||
const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])).annotate({
|
||||
identifier: "SessionPromptInput",
|
||||
})
|
||||
const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])).annotate({
|
||||
identifier: "SessionCommandInput",
|
||||
})
|
||||
const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])).annotate({
|
||||
identifier: "SessionShellInput",
|
||||
})
|
||||
const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])).annotate({
|
||||
identifier: "SessionRevertInput",
|
||||
})
|
||||
const PermissionResponsePayload = Schema.Struct({
|
||||
response: Permission.Reply,
|
||||
}).annotate({ identifier: "SessionPermissionResponseInput" })
|
||||
|
||||
export const SessionPaths = {
|
||||
list: root,
|
||||
status: `${root}/status`,
|
||||
get: `${root}/:sessionID`,
|
||||
children: `${root}/:sessionID/children`,
|
||||
todo: `${root}/:sessionID/todo`,
|
||||
diff: `${root}/:sessionID/diff`,
|
||||
messages: `${root}/:sessionID/message`,
|
||||
message: `${root}/:sessionID/message/:messageID`,
|
||||
create: root,
|
||||
remove: `${root}/:sessionID`,
|
||||
update: `${root}/:sessionID`,
|
||||
fork: `${root}/:sessionID/fork`,
|
||||
abort: `${root}/:sessionID/abort`,
|
||||
share: `${root}/:sessionID/share`,
|
||||
init: `${root}/:sessionID/init`,
|
||||
summarize: `${root}/:sessionID/summarize`,
|
||||
prompt: `${root}/:sessionID/message`,
|
||||
promptAsync: `${root}/:sessionID/prompt_async`,
|
||||
command: `${root}/:sessionID/command`,
|
||||
shell: `${root}/:sessionID/shell`,
|
||||
revert: `${root}/:sessionID/revert`,
|
||||
unrevert: `${root}/:sessionID/unrevert`,
|
||||
permissions: `${root}/:sessionID/permissions/:permissionID`,
|
||||
deleteMessage: `${root}/:sessionID/message/:messageID`,
|
||||
deletePart: `${root}/:sessionID/message/:messageID/part/:partID`,
|
||||
updatePart: `${root}/:sessionID/message/:messageID/part/:partID`,
|
||||
} as const
|
||||
|
||||
export const SessionApi = HttpApi.make("session")
|
||||
.add(
|
||||
HttpApiGroup.make("session")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", SessionPaths.list, {
|
||||
query: ListQuery,
|
||||
success: Schema.Array(Session.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.list",
|
||||
summary: "List sessions",
|
||||
description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", SessionPaths.status, {
|
||||
success: StatusMap,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.status",
|
||||
summary: "Get session status",
|
||||
description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("get", SessionPaths.get, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.get",
|
||||
summary: "Get session",
|
||||
description: "Retrieve detailed information about a specific OpenCode session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("children", SessionPaths.children, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Schema.Array(Session.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.children",
|
||||
summary: "Get session children",
|
||||
description: "Retrieve all child sessions that were forked from the specified parent session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("todo", SessionPaths.todo, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Schema.Array(Todo.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.todo",
|
||||
summary: "Get session todos",
|
||||
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("diff", SessionPaths.diff, {
|
||||
params: { sessionID: SessionID },
|
||||
query: DiffQuery,
|
||||
success: Schema.Array(Snapshot.FileDiff),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.diff",
|
||||
summary: "Get message diff",
|
||||
description: "Get the file changes (diff) that resulted from a specific user message in the session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("messages", SessionPaths.messages, {
|
||||
params: { sessionID: SessionID },
|
||||
query: MessagesQuery,
|
||||
success: Schema.Array(MessageV2.WithParts),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.messages",
|
||||
summary: "Get session messages",
|
||||
description: "Retrieve all messages in a session, including user prompts and AI responses.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("message", SessionPaths.message, {
|
||||
params: { sessionID: SessionID, messageID: MessageID },
|
||||
success: MessageV2.WithParts,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.message",
|
||||
summary: "Get message",
|
||||
description: "Retrieve a specific message from a session by its message ID.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", SessionPaths.create, {
|
||||
payload: [HttpApiSchema.NoContent, Session.CreateInput],
|
||||
success: Session.Info,
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.create",
|
||||
summary: "Create session",
|
||||
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.delete",
|
||||
summary: "Delete session",
|
||||
description: "Delete a session and permanently remove all associated data, including messages and history.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.patch("update", SessionPaths.update, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: UpdatePayload,
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.update",
|
||||
summary: "Update session",
|
||||
description: "Update properties of an existing session, such as title or other metadata.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("fork", SessionPaths.fork, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: ForkPayload,
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.fork",
|
||||
summary: "Fork session",
|
||||
description: "Create a new session by forking an existing session at a specific message point.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("abort", SessionPaths.abort, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.abort",
|
||||
summary: "Abort session",
|
||||
description: "Abort an active session and stop any ongoing AI processing or command execution.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("init", SessionPaths.init, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: InitPayload,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.init",
|
||||
summary: "Initialize session",
|
||||
description:
|
||||
"Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("share", SessionPaths.share, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.share",
|
||||
summary: "Share session",
|
||||
description: "Create a shareable link for a session, allowing others to view the conversation.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.unshare",
|
||||
summary: "Unshare session",
|
||||
description: "Remove the shareable link for a session, making it private again.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: SummarizePayload,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.summarize",
|
||||
summary: "Summarize session",
|
||||
description: "Generate a concise summary of the session using AI compaction to preserve key information.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: PromptPayload,
|
||||
success: MessageV2.WithParts,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.prompt",
|
||||
summary: "Send message",
|
||||
description: "Create and send a new message to a session, streaming the AI response.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: PromptPayload,
|
||||
success: HttpApiSchema.NoContent,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.prompt_async",
|
||||
summary: "Send async message",
|
||||
description:
|
||||
"Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("command", SessionPaths.command, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: CommandPayload,
|
||||
success: MessageV2.WithParts,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.command",
|
||||
summary: "Send command",
|
||||
description: "Send a new command to a session for execution by the AI assistant.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("shell", SessionPaths.shell, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: ShellPayload,
|
||||
success: MessageV2.WithParts,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.shell",
|
||||
summary: "Run shell command",
|
||||
description: "Execute a shell command within the session context and return the AI's response.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("revert", SessionPaths.revert, {
|
||||
params: { sessionID: SessionID },
|
||||
payload: RevertPayload,
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.revert",
|
||||
summary: "Revert message",
|
||||
description:
|
||||
"Revert a specific message in a session, undoing its effects and restoring the previous state.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
|
||||
params: { sessionID: SessionID },
|
||||
success: Session.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.unrevert",
|
||||
summary: "Restore reverted messages",
|
||||
description: "Restore all previously reverted messages in a session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
|
||||
params: { sessionID: SessionID, permissionID: PermissionID },
|
||||
payload: PermissionResponsePayload,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "permission.respond",
|
||||
summary: "Respond to permission",
|
||||
description: "Approve or deny a permission request from the AI assistant.",
|
||||
deprecated: true,
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
|
||||
params: { sessionID: SessionID, messageID: MessageID },
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.deleteMessage",
|
||||
summary: "Delete message",
|
||||
description:
|
||||
"Permanently delete a specific message and all of its parts from a session without reverting file changes.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
|
||||
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "part.delete",
|
||||
description: "Delete a part from a message.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
|
||||
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
|
||||
payload: MessageV2.Part,
|
||||
success: MessageV2.Part,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "part.update",
|
||||
description: "Update a part in a message.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "session",
|
||||
description: "Experimental HttpApi session routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
|
||||
self.pipe(
|
||||
Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))),
|
||||
Effect.catchDefect((error) =>
|
||||
NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error),
|
||||
),
|
||||
)
|
||||
|
||||
export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (handlers) =>
|
||||
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const statusSvc = yield* SessionStatus.Service
|
||||
@@ -462,7 +66,7 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand
|
||||
})
|
||||
|
||||
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
return yield* session.get(ctx.params.sessionID)
|
||||
return yield* mapNotFound(session.get(ctx.params.sessionID))
|
||||
})
|
||||
|
||||
const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
@@ -484,44 +88,47 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand
|
||||
params: { sessionID: SessionID }
|
||||
query: typeof MessagesQuery.Type
|
||||
}) {
|
||||
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
|
||||
if (ctx.query.before) {
|
||||
const before = ctx.query.before
|
||||
yield* Effect.try({
|
||||
try: () => MessageV2.cursor.decode(before),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
}
|
||||
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
|
||||
return yield* mapNotFound(Effect.gen(function* () {
|
||||
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
|
||||
if (ctx.query.before) {
|
||||
const before = ctx.query.before
|
||||
yield* Effect.try({
|
||||
try: () => MessageV2.cursor.decode(before),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
}
|
||||
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
|
||||
yield* session.get(ctx.params.sessionID)
|
||||
return yield* session.messages({ sessionID: ctx.params.sessionID })
|
||||
}
|
||||
|
||||
yield* session.get(ctx.params.sessionID)
|
||||
return yield* session.messages({ sessionID: ctx.params.sessionID })
|
||||
}
|
||||
const page = MessageV2.page({
|
||||
sessionID: ctx.params.sessionID,
|
||||
limit: ctx.query.limit,
|
||||
before: ctx.query.before,
|
||||
})
|
||||
if (!page.cursor) return page.items
|
||||
|
||||
const page = MessageV2.page({
|
||||
sessionID: ctx.params.sessionID,
|
||||
limit: ctx.query.limit,
|
||||
before: ctx.query.before,
|
||||
})
|
||||
if (!page.cursor) return page.items
|
||||
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
url.searchParams.set("limit", ctx.query.limit.toString())
|
||||
url.searchParams.set("before", page.cursor)
|
||||
return HttpServerResponse.jsonUnsafe(page.items, {
|
||||
headers: {
|
||||
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
|
||||
Link: `<${url.toString()}>; rel="next"`,
|
||||
"X-Next-Cursor": page.cursor,
|
||||
},
|
||||
})
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
url.searchParams.set("limit", ctx.query.limit.toString())
|
||||
url.searchParams.set("before", page.cursor)
|
||||
return HttpServerResponse.jsonUnsafe(page.items, {
|
||||
headers: {
|
||||
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
|
||||
Link: `<${url.toString()}>; rel="next"`,
|
||||
"X-Next-Cursor": page.cursor,
|
||||
},
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
|
||||
params: { sessionID: SessionID; messageID: MessageID }
|
||||
}) {
|
||||
return yield* Effect.sync(() =>
|
||||
MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }),
|
||||
return yield* mapNotFound(
|
||||
Effect.sync(() => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { startWorkspaceSyncing } from "@/control-plane/workspace"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { EventTable } from "@/sync/event.sql"
|
||||
import { asc } from "drizzle-orm"
|
||||
import { and } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { lte } from "drizzle-orm"
|
||||
import { not } from "drizzle-orm"
|
||||
import { or } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { HistoryPayload, ReplayPayload } from "../groups/sync"
|
||||
|
||||
export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const start = Effect.fn("SyncHttpApi.start")(function* () {
|
||||
startWorkspaceSyncing((yield* InstanceState.context).project.id)
|
||||
return true
|
||||
})
|
||||
|
||||
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
|
||||
const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({
|
||||
id: event.id,
|
||||
aggregateID: event.aggregateID,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: { ...event.data },
|
||||
}))
|
||||
SyncEvent.replayAll(events)
|
||||
return { sessionID: events[0].aggregateID }
|
||||
})
|
||||
|
||||
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
|
||||
const exclude = Object.entries(ctx.payload)
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(
|
||||
exclude.length > 0
|
||||
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
|
||||
: undefined,
|
||||
)
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all(),
|
||||
)
|
||||
})
|
||||
|
||||
return handlers.handle("start", start).handle("replay", replay).handle("history", history)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import * as Database from "@/storage/db"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import { nextTuiRequest, submitTuiResponse } from "../../tui"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { CommandPayload, TuiPublishPayload } from "../groups/tui"
|
||||
|
||||
const commandAliases = {
|
||||
session_new: "session.new",
|
||||
session_share: "session.share",
|
||||
session_interrupt: "session.interrupt",
|
||||
session_compact: "session.compact",
|
||||
messages_page_up: "session.page.up",
|
||||
messages_page_down: "session.page.down",
|
||||
messages_line_up: "session.line.up",
|
||||
messages_line_down: "session.line.down",
|
||||
messages_half_page_up: "session.half.page.up",
|
||||
messages_half_page_down: "session.half.page.down",
|
||||
messages_first: "session.first",
|
||||
messages_last: "session.last",
|
||||
agent_cycle: "agent.cycle",
|
||||
} as const
|
||||
|
||||
export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
|
||||
bus.publish(TuiEvent.CommandExecute, { command })
|
||||
|
||||
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
|
||||
payload: typeof TuiEvent.PromptAppend.properties.Type
|
||||
}) {
|
||||
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () {
|
||||
yield* publishCommand("help.show")
|
||||
return true
|
||||
})
|
||||
|
||||
const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () {
|
||||
yield* publishCommand("session.list")
|
||||
return true
|
||||
})
|
||||
|
||||
const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () {
|
||||
yield* publishCommand("session.list")
|
||||
return true
|
||||
})
|
||||
|
||||
const openModels = Effect.fn("TuiHttpApi.openModels")(function* () {
|
||||
yield* publishCommand("model.list")
|
||||
return true
|
||||
})
|
||||
|
||||
const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () {
|
||||
yield* publishCommand("prompt.submit")
|
||||
return true
|
||||
})
|
||||
|
||||
const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () {
|
||||
yield* publishCommand("prompt.clear")
|
||||
return true
|
||||
})
|
||||
|
||||
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
|
||||
payload: typeof CommandPayload.Type
|
||||
}) {
|
||||
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
|
||||
return true
|
||||
})
|
||||
|
||||
const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: {
|
||||
payload: typeof TuiEvent.ToastShow.properties.Type
|
||||
}) {
|
||||
yield* bus.publish(TuiEvent.ToastShow, ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) {
|
||||
if (ctx.payload.type === TuiEvent.PromptAppend.type)
|
||||
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties)
|
||||
if (ctx.payload.type === TuiEvent.CommandExecute.type)
|
||||
yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties)
|
||||
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties)
|
||||
if (ctx.payload.type === TuiEvent.SessionSelect.type)
|
||||
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties)
|
||||
return true
|
||||
})
|
||||
|
||||
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
|
||||
payload: typeof TuiEvent.SessionSelect.properties.Type
|
||||
}) {
|
||||
if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({})
|
||||
const row = yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(),
|
||||
),
|
||||
)
|
||||
if (!row) return yield* new HttpApiError.NotFound({})
|
||||
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () {
|
||||
return yield* Effect.promise(() => nextTuiRequest())
|
||||
})
|
||||
|
||||
const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) {
|
||||
submitTuiResponse(ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("appendPrompt", appendPrompt)
|
||||
.handle("openHelp", openHelp)
|
||||
.handle("openSessions", openSessions)
|
||||
.handle("openThemes", openThemes)
|
||||
.handle("openModels", openModels)
|
||||
.handle("submitPrompt", submitPrompt)
|
||||
.handle("clearPrompt", clearPrompt)
|
||||
.handle("executeCommand", executeCommand)
|
||||
.handle("showToast", showToast)
|
||||
.handle("publish", publish)
|
||||
.handle("selectSession", selectSession)
|
||||
.handle("controlNext", controlNext)
|
||||
.handle("controlResponse", controlResponse)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
import { listAdaptors } from "@/control-plane/adaptors"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { CreatePayload, SessionRestorePayload } from "../groups/workspace"
|
||||
|
||||
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() => listAdaptors(instance.project.id))
|
||||
})
|
||||
|
||||
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
|
||||
return Workspace.list((yield* InstanceState.context).project)
|
||||
})
|
||||
|
||||
const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
Workspace.create({
|
||||
...ctx.payload,
|
||||
projectID: instance.project.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
|
||||
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
|
||||
return Workspace.status().filter((item) => ids.has(item.workspaceID))
|
||||
})
|
||||
|
||||
const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
|
||||
})
|
||||
|
||||
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
|
||||
params: { id: Workspace.Info["id"] }
|
||||
payload: typeof SessionRestorePayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
Workspace.sessionRestore({
|
||||
workspaceID: ctx.params.id,
|
||||
sessionID: ctx.payload.sessionID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("adaptors", adaptors)
|
||||
.handle("list", list)
|
||||
.handle("create", create)
|
||||
.handle("status", status)
|
||||
.handle("remove", remove)
|
||||
.handle("sessionRestore", sessionRestore)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { getAdaptor } from "@/control-plane/adaptors"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import type { Target } from "@/control-plane/types"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Session } from "@/session/session"
|
||||
import { ServerProxy } from "@/server/proxy"
|
||||
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import type { unhandled } from "effect/Types"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
|
||||
type HandlerEffect = Effect.Effect<HttpServerResponse.HttpServerResponse, unhandled, never>
|
||||
|
||||
export class InstanceContextMiddleware extends HttpApiMiddleware.Service<InstanceContextMiddleware, {
|
||||
requires: Session.Service
|
||||
}>()(
|
||||
"@opencode/ExperimentalHttpApiInstanceContext",
|
||||
) {}
|
||||
|
||||
function decode(input: string) {
|
||||
try {
|
||||
return decodeURIComponent(input)
|
||||
} catch {
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
function currentDirectory() {
|
||||
try {
|
||||
return Instance.directory
|
||||
} catch {
|
||||
return process.cwd()
|
||||
}
|
||||
}
|
||||
|
||||
function sourceRequest(request: HttpServerRequest.HttpServerRequest) {
|
||||
if (request.source instanceof Request) return request.source
|
||||
return new Request(new URL(request.originalUrl, "http://localhost"), {
|
||||
method: request.method,
|
||||
headers: request.headers as HeadersInit,
|
||||
})
|
||||
}
|
||||
|
||||
function requestHeaders(request: HttpServerRequest.HttpServerRequest) {
|
||||
return sourceRequest(request).headers
|
||||
}
|
||||
|
||||
function writeSocket(write: (data: string | Uint8Array | Socket.CloseEvent) => Effect.Effect<void, unknown>, data: unknown) {
|
||||
if (data instanceof Blob) {
|
||||
void data.arrayBuffer().then((buffer) => Effect.runFork(write(new Uint8Array(buffer)).pipe(Effect.catch(() => Effect.void))))
|
||||
return
|
||||
}
|
||||
if (typeof data === "string" || data instanceof Uint8Array) {
|
||||
Effect.runFork(write(data).pipe(Effect.catch(() => Effect.void)))
|
||||
return
|
||||
}
|
||||
if (data instanceof ArrayBuffer) Effect.runFork(write(new Uint8Array(data)).pipe(Effect.catch(() => Effect.void)))
|
||||
}
|
||||
|
||||
function proxyWebSocket(request: HttpServerRequest.HttpServerRequest, target: string | URL) {
|
||||
return Effect.gen(function* () {
|
||||
const source = sourceRequest(request)
|
||||
const socket = yield* Effect.orDie(request.upgrade)
|
||||
const write = yield* socket.writer
|
||||
const queue: Array<string | Uint8Array> = []
|
||||
const remote = new WebSocket(ServerProxy.websocketTargetURL(target), ServerProxy.websocketProtocols(source))
|
||||
remote.binaryType = "arraybuffer"
|
||||
remote.onopen = () => {
|
||||
for (const item of queue) remote.send(item)
|
||||
queue.length = 0
|
||||
}
|
||||
remote.onmessage = (event) => writeSocket(write, event.data)
|
||||
remote.onerror = () => Effect.runFork(write(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void)))
|
||||
remote.onclose = (event) =>
|
||||
Effect.runFork(write(new Socket.CloseEvent(event.code, event.reason)).pipe(Effect.catch(() => Effect.void)))
|
||||
|
||||
yield* socket
|
||||
.runRaw((message) => {
|
||||
const data = typeof message === "string" ? message : message.slice()
|
||||
if (remote.readyState === WebSocket.OPEN) {
|
||||
remote.send(data)
|
||||
return
|
||||
}
|
||||
queue.push(data)
|
||||
})
|
||||
.pipe(
|
||||
Effect.catch(() => Effect.void),
|
||||
Effect.ensuring(Effect.sync(() => remote.close())),
|
||||
Effect.orDie,
|
||||
)
|
||||
return HttpServerResponse.empty()
|
||||
})
|
||||
}
|
||||
|
||||
function proxyRemote(
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
workspace: Workspace.Info,
|
||||
target: Extract<Target, { type: "remote" }>,
|
||||
requestURL: URL,
|
||||
) {
|
||||
const url = workspaceProxyURL(target.url, requestURL)
|
||||
const source = sourceRequest(request)
|
||||
if (source.headers.get("upgrade")?.toLowerCase() === "websocket") return proxyWebSocket(request, url)
|
||||
return Effect.promise(() => ServerProxy.http(url, target.headers, source, workspace.id)).pipe(Effect.map(HttpServerResponse.raw))
|
||||
}
|
||||
|
||||
function requestContext() {
|
||||
return Effect.withFiber<HttpServerRequest.HttpServerRequest, never>((fiber) =>
|
||||
Effect.succeed(Context.getUnsafe(fiber.context, HttpServerRequest.HttpServerRequest)),
|
||||
)
|
||||
}
|
||||
|
||||
function provideRequestContext(effect: HandlerEffect, request: HttpServerRequest.HttpServerRequest, sessionWorkspaceID?: WorkspaceID) {
|
||||
return Effect.gen(function* () {
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
const headers = requestHeaders(request)
|
||||
const envWorkspaceID = Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined
|
||||
const workspaceParam = url.searchParams.get("workspace")
|
||||
const workspaceID = sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined)
|
||||
const workspace = workspaceID && !envWorkspaceID ? yield* Effect.promise(() => Workspace.get(workspaceID)) : undefined
|
||||
|
||||
if (workspaceID && !workspace && !envWorkspaceID) {
|
||||
return HttpServerResponse.text(`Workspace not found: ${workspaceID}`, {
|
||||
status: 500,
|
||||
contentType: "text/plain; charset=utf-8",
|
||||
})
|
||||
}
|
||||
|
||||
if (workspace && !isLocalWorkspaceRoute(request.method, url.pathname) && !url.pathname.startsWith("/console") && !envWorkspaceID) {
|
||||
const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type))
|
||||
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace)))
|
||||
if (target.type === "remote") return yield* proxyRemote(request, workspace, target, url)
|
||||
const ctx = yield* Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: target.directory,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
fn: () => Instance.current,
|
||||
}),
|
||||
)
|
||||
return yield* effect.pipe(
|
||||
Effect.provideService(InstanceRef, ctx),
|
||||
Effect.provideService(WorkspaceRef, workspace.id),
|
||||
)
|
||||
}
|
||||
|
||||
const raw = url.searchParams.get("directory") || headers.get("x-opencode-directory") || currentDirectory()
|
||||
const ctx = yield* Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: Filesystem.resolve(decode(raw)),
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
fn: () => Instance.current,
|
||||
}),
|
||||
)
|
||||
|
||||
return yield* effect.pipe(
|
||||
Effect.provideService(InstanceRef, ctx),
|
||||
Effect.provideService(WorkspaceRef, envWorkspaceID ?? workspaceID),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function provideInstanceContext(effect: HandlerEffect) {
|
||||
return Effect.gen(function* () {
|
||||
const request = yield* requestContext()
|
||||
const sessionID = getWorkspaceRouteSessionID(new URL(request.url, "http://localhost"))
|
||||
const session = sessionID
|
||||
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.catchDefect(() => Effect.succeed(undefined)),
|
||||
)
|
||||
: undefined
|
||||
return yield* provideRequestContext(effect, request, session?.workspaceID)
|
||||
})
|
||||
}
|
||||
|
||||
export const instanceContextLayer = Layer.succeed(
|
||||
InstanceContextMiddleware,
|
||||
InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)),
|
||||
)
|
||||
|
||||
export const instanceRouterLayer = HttpRouter.middleware()(Effect.succeed((effect) =>
|
||||
requestContext().pipe(Effect.flatMap((request) => provideRequestContext(effect, request))),
|
||||
)).layer
|
||||
@@ -3,7 +3,6 @@ import { Effect } from "effect"
|
||||
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
|
||||
|
||||
const disposeAfterResponse = new WeakMap<object, InstanceContext>()
|
||||
const reloadAfterResponse = new WeakMap<object, InstanceContext & { next: Parameters<typeof Instance.reload>[0] }>()
|
||||
|
||||
export const markInstanceForDisposal = (ctx: InstanceContext) =>
|
||||
HttpEffect.appendPreResponseHandler((request, response) =>
|
||||
@@ -14,27 +13,17 @@ export const markInstanceForDisposal = (ctx: InstanceContext) =>
|
||||
)
|
||||
|
||||
export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
|
||||
HttpEffect.appendPreResponseHandler((request, response) =>
|
||||
Effect.sync(() => {
|
||||
reloadAfterResponse.set(request.source, { ...ctx, next })
|
||||
return response
|
||||
}),
|
||||
HttpEffect.appendPreResponseHandler((_request, response) =>
|
||||
Effect.as(Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))), response),
|
||||
)
|
||||
|
||||
export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* effect
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const reload = reloadAfterResponse.get(request.source)
|
||||
if (reload) {
|
||||
reloadAfterResponse.delete(request.source)
|
||||
yield* Effect.promise(() => Instance.restore(reload, () => Instance.reload(reload.next)))
|
||||
return response
|
||||
}
|
||||
|
||||
const ctx = disposeAfterResponse.get(request.source)
|
||||
if (!ctx) return response
|
||||
disposeAfterResponse.delete(request.source)
|
||||
yield* Effect.promise(() => Instance.restore(ctx, () => Instance.dispose()))
|
||||
yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose())))
|
||||
return response
|
||||
})
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { Config } from "@/config/config"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { mapValues } from "remeda"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
|
||||
const root = "/provider"
|
||||
|
||||
export const ProviderApi = HttpApi.make("provider")
|
||||
.add(
|
||||
HttpApiGroup.make("provider")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
success: Provider.ListResult,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "provider.list",
|
||||
summary: "List providers",
|
||||
description: "Get a list of all available AI providers, including both available and connected ones.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("auth", `${root}/auth`, {
|
||||
success: ProviderAuth.Methods,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "provider.auth",
|
||||
summary: "Get provider auth methods",
|
||||
description: "Retrieve available authentication methods for all AI providers.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
|
||||
params: { providerID: ProviderID },
|
||||
payload: ProviderAuth.AuthorizeInput,
|
||||
success: Schema.UndefinedOr(ProviderAuth.Authorization),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "provider.oauth.authorize",
|
||||
summary: "Start OAuth authorization",
|
||||
description: "Start the OAuth authorization flow for a provider.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
|
||||
params: { providerID: ProviderID },
|
||||
payload: ProviderAuth.CallbackInput,
|
||||
success: Schema.Boolean,
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "provider.oauth.callback",
|
||||
summary: "Handle OAuth callback",
|
||||
description: "Handle the OAuth callback from a provider after user authorization.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "provider",
|
||||
description: "Experimental HttpApi provider routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const providerHandlers = HttpApiBuilder.group(ProviderApi, "provider", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const provider = yield* Provider.Service
|
||||
const svc = yield* ProviderAuth.Service
|
||||
|
||||
const list = Effect.fn("ProviderHttpApi.list")(function* () {
|
||||
const config = yield* cfg.get()
|
||||
const all = yield* Effect.promise(() => ModelsDev.get())
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
const filtered: Record<string, (typeof all)[string]> = {}
|
||||
for (const [key, value] of Object.entries(all)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
const connected = yield* provider.list()
|
||||
const providers = Object.assign(
|
||||
mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)),
|
||||
connected,
|
||||
)
|
||||
return {
|
||||
all: Object.values(providers),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
connected: Object.keys(connected),
|
||||
}
|
||||
})
|
||||
|
||||
const auth = Effect.fn("ProviderHttpApi.auth")(function* () {
|
||||
return yield* svc.methods()
|
||||
})
|
||||
|
||||
const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: {
|
||||
params: { providerID: ProviderID }
|
||||
payload: ProviderAuth.AuthorizeInput
|
||||
}) {
|
||||
const result = yield* svc
|
||||
.authorize({
|
||||
providerID: ctx.params.providerID,
|
||||
method: ctx.payload.method,
|
||||
inputs: ctx.payload.inputs,
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
|
||||
return result
|
||||
})
|
||||
|
||||
const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: {
|
||||
params: { providerID: ProviderID }
|
||||
request: HttpServerRequest.HttpServerRequest
|
||||
}) {
|
||||
const body = yield* Effect.orDie(ctx.request.text)
|
||||
const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe(
|
||||
Effect.mapError(() => new HttpApiError.BadRequest({})),
|
||||
)
|
||||
const result = yield* authorize({ params: ctx.params, payload })
|
||||
if (result === undefined) return HttpServerResponse.empty({ status: 200 })
|
||||
return HttpServerResponse.jsonUnsafe(result)
|
||||
})
|
||||
|
||||
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
|
||||
params: { providerID: ProviderID }
|
||||
payload: ProviderAuth.CallbackInput
|
||||
}) {
|
||||
yield* svc
|
||||
.callback({
|
||||
providerID: ctx.params.providerID,
|
||||
method: ctx.payload.method,
|
||||
code: ctx.payload.code,
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("list", list)
|
||||
.handle("auth", auth)
|
||||
.handleRaw("authorize", authorizeRaw)
|
||||
.handle("callback", callback)
|
||||
}),
|
||||
)
|
||||
@@ -1,242 +0,0 @@
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { Authorization } from "./auth"
|
||||
|
||||
const root = "/pty"
|
||||
const Params = Schema.Struct({
|
||||
ptyID: PtyID,
|
||||
})
|
||||
const CursorQuery = Schema.Struct({
|
||||
cursor: Schema.optional(Schema.String),
|
||||
})
|
||||
const ShellItem = Schema.Struct({
|
||||
path: Schema.String,
|
||||
name: Schema.String,
|
||||
acceptable: Schema.Boolean,
|
||||
})
|
||||
|
||||
export const PtyPaths = {
|
||||
shells: `${root}/shells`,
|
||||
list: root,
|
||||
create: root,
|
||||
get: `${root}/:ptyID`,
|
||||
update: `${root}/:ptyID`,
|
||||
remove: `${root}/:ptyID`,
|
||||
connect: `${root}/:ptyID/connect`,
|
||||
} as const
|
||||
|
||||
export const PtyApi = HttpApi.make("pty")
|
||||
.add(
|
||||
HttpApiGroup.make("pty")
|
||||
.add(
|
||||
HttpApiEndpoint.get("shells", PtyPaths.shells, {
|
||||
success: Schema.Array(ShellItem),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.shells",
|
||||
summary: "List available shells",
|
||||
description: "Get a list of available shells on the system.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("list", PtyPaths.list, {
|
||||
success: Schema.Array(Pty.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.list",
|
||||
summary: "List PTY sessions",
|
||||
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", PtyPaths.create, {
|
||||
payload: Pty.CreateInput,
|
||||
success: Pty.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.create",
|
||||
summary: "Create PTY session",
|
||||
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("get", PtyPaths.get, {
|
||||
params: { ptyID: PtyID },
|
||||
success: Pty.Info,
|
||||
error: HttpApiError.NotFound,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.get",
|
||||
summary: "Get PTY session",
|
||||
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.put("update", PtyPaths.update, {
|
||||
params: { ptyID: PtyID },
|
||||
payload: Pty.UpdateInput,
|
||||
success: Pty.Info,
|
||||
error: HttpApiError.NotFound,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.update",
|
||||
summary: "Update PTY session",
|
||||
description: "Update properties of an existing pseudo-terminal (PTY) session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
|
||||
params: { ptyID: PtyID },
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.remove",
|
||||
summary: "Remove PTY session",
|
||||
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "pty",
|
||||
description: "Experimental HttpApi PTY routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const PtyConnectApi = HttpApi.make("pty-connect").add(
|
||||
HttpApiGroup.make("pty-connect")
|
||||
.add(
|
||||
HttpApiEndpoint.get("connect", PtyPaths.connect, {
|
||||
params: Params,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.connect",
|
||||
summary: "Connect to PTY session",
|
||||
description:
|
||||
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
|
||||
)
|
||||
|
||||
export const ptyHandlers = HttpApiBuilder.group(PtyApi, "pty", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
|
||||
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
|
||||
return yield* Effect.promise(() => Shell.list())
|
||||
})
|
||||
|
||||
const list = Effect.fn("PtyHttpApi.list")(function* () {
|
||||
return yield* pty.list()
|
||||
})
|
||||
|
||||
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
|
||||
const bridge = yield* EffectBridge.make()
|
||||
return yield* Effect.promise(() =>
|
||||
bridge.promise(
|
||||
pty.create({
|
||||
...ctx.payload,
|
||||
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
||||
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
const info = yield* pty.get(ctx.params.ptyID)
|
||||
if (!info) return yield* new HttpApiError.NotFound({})
|
||||
return info
|
||||
})
|
||||
|
||||
const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
|
||||
params: { ptyID: PtyID }
|
||||
payload: typeof Pty.UpdateInput.Type
|
||||
}) {
|
||||
const info = yield* pty.update(ctx.params.ptyID, {
|
||||
...ctx.payload,
|
||||
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
|
||||
})
|
||||
if (!info) return yield* new HttpApiError.NotFound({})
|
||||
return info
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
yield* pty.remove(ctx.params.ptyID)
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("shells", shells)
|
||||
.handle("list", list)
|
||||
.handle("create", create)
|
||||
.handle("get", get)
|
||||
.handle("update", update)
|
||||
.handle("remove", remove)
|
||||
}),
|
||||
)
|
||||
|
||||
export const ptyConnectRoute = HttpRouter.add(
|
||||
"GET",
|
||||
PtyPaths.connect,
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const params = yield* HttpRouter.schemaPathParams(Params)
|
||||
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
|
||||
|
||||
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
|
||||
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
|
||||
const cursor =
|
||||
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
|
||||
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
|
||||
const write = yield* socket.writer
|
||||
let closed = false
|
||||
const adapter = {
|
||||
get readyState() {
|
||||
return closed ? 3 : 1
|
||||
},
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => {
|
||||
if (closed) return
|
||||
Effect.runFork(
|
||||
write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)),
|
||||
)
|
||||
},
|
||||
close: (code?: number, reason?: string) => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
|
||||
},
|
||||
}
|
||||
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
|
||||
if (!handler) return HttpServerResponse.empty()
|
||||
|
||||
yield* socket
|
||||
.runRaw((message) => {
|
||||
handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
closed = true
|
||||
handler.onClose()
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
return HttpServerResponse.empty()
|
||||
}).pipe(Effect.provide(Pty.defaultLayer)),
|
||||
)
|
||||
@@ -1,21 +1,5 @@
|
||||
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
|
||||
import { ConfigApi } from "./config"
|
||||
import { ControlApi } from "./control"
|
||||
import { EventApi } from "./event"
|
||||
import { ExperimentalApi } from "./experimental"
|
||||
import { FileApi } from "./file"
|
||||
import { GlobalApi } from "./global"
|
||||
import { InstanceApi } from "./instance"
|
||||
import { McpApi } from "./mcp"
|
||||
import { PermissionApi } from "./permission"
|
||||
import { ProjectApi } from "./project"
|
||||
import { ProviderApi } from "./provider"
|
||||
import { PtyApi, PtyConnectApi } from "./pty"
|
||||
import { QuestionApi } from "./question"
|
||||
import { SessionApi } from "./session"
|
||||
import { SyncApi } from "./sync"
|
||||
import { TuiApi } from "./tui"
|
||||
import { WorkspaceApi } from "./workspace"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { OpenCodeHttpApi } from "./api"
|
||||
|
||||
type OpenApiParameter = {
|
||||
name: string
|
||||
@@ -26,11 +10,12 @@ type OpenApiParameter = {
|
||||
|
||||
type OpenApiOperation = {
|
||||
parameters?: OpenApiParameter[]
|
||||
responses?: Record<string, unknown>
|
||||
responses?: Record<string, OpenApiResponse>
|
||||
requestBody?: {
|
||||
required?: boolean
|
||||
content?: Record<string, { schema?: OpenApiSchema }>
|
||||
}
|
||||
security?: unknown
|
||||
}
|
||||
|
||||
type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
|
||||
@@ -38,6 +23,7 @@ type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch
|
||||
type OpenApiSpec = {
|
||||
components?: {
|
||||
schemas?: Record<string, OpenApiSchema>
|
||||
securitySchemes?: Record<string, unknown>
|
||||
}
|
||||
paths?: Record<string, OpenApiPathItem>
|
||||
}
|
||||
@@ -47,16 +33,25 @@ type OpenApiSchema = {
|
||||
additionalProperties?: OpenApiSchema | boolean
|
||||
allOf?: OpenApiSchema[]
|
||||
anyOf?: OpenApiSchema[]
|
||||
enum?: string[]
|
||||
description?: string
|
||||
enum?: Array<string | boolean>
|
||||
items?: OpenApiSchema
|
||||
maximum?: number
|
||||
minimum?: number
|
||||
oneOf?: OpenApiSchema[]
|
||||
prefixItems?: OpenApiSchema[]
|
||||
properties?: Record<string, OpenApiSchema>
|
||||
required?: string[]
|
||||
type?: string
|
||||
}
|
||||
|
||||
type OpenApiResponse = {
|
||||
description?: string
|
||||
content?: Record<string, { schema?: OpenApiSchema }>
|
||||
}
|
||||
|
||||
// Instance routes use middleware for directory/workspace resolution, but HttpApi
|
||||
// doesn't surface middleware query params in the spec. Inject them explicitly.
|
||||
const InstanceQueryParameters = [
|
||||
{
|
||||
name: "directory",
|
||||
@@ -72,8 +67,9 @@ const InstanceQueryParameters = [
|
||||
},
|
||||
] satisfies OpenApiParameter[]
|
||||
|
||||
const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"])
|
||||
const FiniteNumberValues = new Set(["Infinity", "-Infinity", "NaN"])
|
||||
// Query schemas describe decoded Effect values, but the generated SDK needs the
|
||||
// public call shape. These keep SDK callers passing numbers/booleans while the
|
||||
// server still decodes string query params at runtime.
|
||||
const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
|
||||
const QueryBooleanParameters = new Set(["roots", "archived"])
|
||||
const QueryParameterSchemas = {
|
||||
@@ -81,60 +77,80 @@ const QueryParameterSchemas = {
|
||||
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||
} satisfies Record<string, OpenApiSchema>
|
||||
|
||||
const LegacyComponentDescriptions = {
|
||||
LogLevel: "Log level",
|
||||
ServerConfig: "Server configuration for opencode serve and web commands",
|
||||
LayoutConfig: "@deprecated Always uses stretch layout.",
|
||||
} satisfies Record<string, string>
|
||||
|
||||
function matchLegacyOpenApi(input: Record<string, unknown>) {
|
||||
const spec = input as OpenApiSpec
|
||||
|
||||
// Effect's multi-document JSON Schema deduplicator can produce self-referencing
|
||||
// component schemas (e.g. `{"$ref":"#/components/schemas/X"}` as the definition
|
||||
// of X itself) when the same AST node appears both as a standalone endpoint
|
||||
// payload and inside an annotated union arm. Resolve these by inlining the
|
||||
// actual schema from any parent union that references them.
|
||||
fixSelfReferencingComponents(spec)
|
||||
|
||||
// Effect's Schema.optional emits `anyOf: [T, {type:"null"}]` in OpenAPI,
|
||||
// but the legacy SDK expected plain `T` for optional fields. Strip null
|
||||
// from all component schemas so both request and response types match.
|
||||
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
|
||||
spec.components!.schemas![name] = stripOptionalNull(structuredClone(schema))
|
||||
}
|
||||
normalizeComponentNames(spec)
|
||||
collapseDuplicateComponents(spec)
|
||||
applyLegacySchemaOverrides(spec)
|
||||
normalizeComponentDescriptions(spec)
|
||||
addLegacyErrorSchemas(spec)
|
||||
delete spec.components?.schemas?.Unauthorized
|
||||
delete spec.components?.schemas?.EffectHttpApiErrorBadRequest
|
||||
delete spec.components?.schemas?.EffectHttpApiErrorNotFound
|
||||
delete spec.components?.schemas?.effect_HttpApiError_BadRequest
|
||||
delete spec.components?.schemas?.effect_HttpApiError_NotFound
|
||||
delete spec.components?.securitySchemes
|
||||
|
||||
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
||||
const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const operation = item[method]
|
||||
if (!operation) continue
|
||||
if (operation.requestBody) {
|
||||
// Hono's generated OpenAPI never marked request bodies as required. Keep
|
||||
// that SDK surface stable during the HttpApi migration.
|
||||
delete operation.requestBody.required
|
||||
for (const media of Object.values(operation.requestBody.content ?? {})) {
|
||||
const ref = media.schema?.$ref?.replace("#/components/schemas/", "")
|
||||
if (ref && LegacyBodyRefParameters.has(ref)) continue
|
||||
if (ref && spec.components?.schemas?.[ref]) {
|
||||
media.schema = normalizeRequestSchema(structuredClone(spec.components.schemas[ref]))
|
||||
continue
|
||||
}
|
||||
if (media.schema) media.schema = normalizeRequestSchema(media.schema)
|
||||
}
|
||||
const body = operation.requestBody.content?.["application/json"]
|
||||
if (body?.schema) body.schema = stripOptionalNull(structuredClone(body.schema))
|
||||
if (path === "/experimental/workspace" && method === "post") {
|
||||
const properties = operation.requestBody.content?.["application/json"]?.schema?.properties
|
||||
// Workspace creation fields `branch` and `extra` are Schema.NullOr —
|
||||
// genuinely nullable, not just optional. Re-add the null that the
|
||||
// component-level strip above removed.
|
||||
const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace("#/components/schemas/", "")
|
||||
const properties = ref ? spec.components?.schemas?.[ref]?.properties : operation.requestBody.content?.["application/json"]?.schema?.properties
|
||||
if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] }
|
||||
if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] }
|
||||
}
|
||||
if (path === "/tui/publish" && method === "post" && spec.components?.schemas) {
|
||||
const schema = operation.requestBody.content?.["application/json"]?.schema
|
||||
const anyOf = schema?.anyOf
|
||||
if (anyOf?.length === 4) {
|
||||
spec.components.schemas.EventTuiPromptAppend = anyOf[0]
|
||||
spec.components.schemas.EventTuiCommandExecute = anyOf[1]
|
||||
spec.components.schemas.EventTuiToastShow = anyOf[2]
|
||||
spec.components.schemas.EventTuiSessionSelect = anyOf[3]
|
||||
operation.requestBody.content!["application/json"]!.schema = {
|
||||
anyOf: [
|
||||
{ $ref: "#/components/schemas/EventTuiPromptAppend" },
|
||||
{ $ref: "#/components/schemas/EventTuiCommandExecute" },
|
||||
{ $ref: "#/components/schemas/EventTuiToastShow" },
|
||||
{ $ref: "#/components/schemas/EventTuiSessionSelect" },
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
if (path === "/sync/replay" && method === "post" && spec.components?.schemas?.SyncReplayEvent) {
|
||||
const events = operation.requestBody.content?.["application/json"]?.schema?.properties?.events
|
||||
if (events?.items?.$ref === "#/components/schemas/SyncReplayEvent") {
|
||||
events.items = normalizeRequestSchema(structuredClone(spec.components.schemas.SyncReplayEvent))
|
||||
}
|
||||
}
|
||||
for (const response of Object.values(operation.responses ?? {})) {
|
||||
for (const content of Object.values(response.content ?? {})) {
|
||||
if (content.schema) content.schema = stripOptionalNull(structuredClone(content.schema))
|
||||
}
|
||||
}
|
||||
// Hono applied auth as runtime middleware outside OpenAPI metadata, so the
|
||||
// legacy SDK did not expose auth schemes or generated 401 error unions.
|
||||
delete operation.security
|
||||
delete operation.responses?.["401"]
|
||||
normalizeLegacyErrorResponses(operation)
|
||||
normalizeLegacyOperation(operation, path, method)
|
||||
if ((path === "/event" || path === "/global/event") && method === "get") {
|
||||
// HttpApi has no first-class SSE response schema, and these handlers are
|
||||
// raw/streaming routes. Document the actual wire protocol explicitly.
|
||||
operation.responses!["200"] = {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: path === "/event" ? {} : { $ref: "#/components/schemas/GlobalEvent" },
|
||||
schema: path === "/event" ? { $ref: "#/components/schemas/Event" } : { $ref: "#/components/schemas/GlobalEvent" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -152,40 +168,302 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
|
||||
return input
|
||||
}
|
||||
|
||||
function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema {
|
||||
function addLegacyErrorSchemas(spec: OpenApiSpec) {
|
||||
if (!spec.components?.schemas) return
|
||||
spec.components.schemas.BadRequestError = {
|
||||
type: "object",
|
||||
required: ["data", "errors", "success"],
|
||||
properties: {
|
||||
data: {},
|
||||
errors: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: {},
|
||||
},
|
||||
},
|
||||
success: { type: "boolean", enum: [false] },
|
||||
},
|
||||
}
|
||||
spec.components.schemas.NotFoundError = {
|
||||
type: "object",
|
||||
required: ["name", "data"],
|
||||
properties: {
|
||||
name: { type: "string", enum: ["NotFoundError"] },
|
||||
data: {
|
||||
type: "object",
|
||||
required: ["message"],
|
||||
properties: {
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function collapseDuplicateComponents(spec: OpenApiSpec) {
|
||||
const schemas = spec.components?.schemas
|
||||
if (!schemas) return
|
||||
for (const name of Object.keys(schemas)) {
|
||||
const base = name.replace(/\d+$/, "")
|
||||
if (base === name || !schemas[base]) continue
|
||||
if (stableSchema(schemas[name], schemas) !== stableSchema(schemas[base], schemas)) continue
|
||||
rewriteRefs(spec, name, base)
|
||||
delete schemas[name]
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeComponentNames(spec: OpenApiSpec) {
|
||||
const schemas = spec.components?.schemas
|
||||
if (!schemas) return
|
||||
for (const name of Object.keys(schemas)) {
|
||||
const next = componentTypeName(name)
|
||||
if (next === name) continue
|
||||
if (schemas[next]) {
|
||||
if (stableSchema(schemas[name], schemas) === stableSchema(schemas[next], schemas)) {
|
||||
rewriteRefs(spec, name, next)
|
||||
delete schemas[name]
|
||||
}
|
||||
continue
|
||||
}
|
||||
schemas[next] = schemas[name]
|
||||
rewriteRefs(spec, name, next)
|
||||
delete schemas[name]
|
||||
}
|
||||
}
|
||||
|
||||
function componentTypeName(name: string) {
|
||||
if (!name.includes(".")) return name
|
||||
return name
|
||||
.split(".")
|
||||
.filter((part) => !/^\d+$/.test(part))
|
||||
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
||||
.join("")
|
||||
}
|
||||
|
||||
function applyLegacySchemaOverrides(spec: OpenApiSpec) {
|
||||
const schemas = spec.components?.schemas
|
||||
if (!schemas) return
|
||||
if (schemas.AgentConfig) schemas.AgentConfig.additionalProperties = {}
|
||||
if (schemas.Command?.properties?.template) schemas.Command.properties.template = { type: "string" }
|
||||
if (schemas.Workspace?.properties) {
|
||||
schemas.Workspace.properties.branch = nullable(schemas.Workspace.properties.branch)
|
||||
schemas.Workspace.properties.directory = nullable(schemas.Workspace.properties.directory)
|
||||
schemas.Workspace.properties.extra = nullable(schemas.Workspace.properties.extra)
|
||||
}
|
||||
if (schemas.GlobalSession?.properties?.project) schemas.GlobalSession.properties.project = nullable(schemas.GlobalSession.properties.project)
|
||||
const providerOptions = schemas.ProviderConfig?.properties?.options
|
||||
if (providerOptions) providerOptions.additionalProperties = {}
|
||||
const model = schemas.ProviderConfig?.properties?.models?.additionalProperties
|
||||
const variants = typeof model === "object" ? model.properties?.variants?.additionalProperties : undefined
|
||||
if (variants && typeof variants === "object") variants.additionalProperties = {}
|
||||
const syncInfo = schemas.SyncEventSessionUpdated?.properties?.data?.properties?.info
|
||||
if (syncInfo?.properties) makePropertiesNullable(syncInfo.properties)
|
||||
}
|
||||
|
||||
function normalizeComponentDescriptions(spec: OpenApiSpec) {
|
||||
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
|
||||
const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions]
|
||||
if (description) {
|
||||
schema.description = description
|
||||
continue
|
||||
}
|
||||
delete schema.description
|
||||
}
|
||||
}
|
||||
|
||||
function makePropertiesNullable(properties: Record<string, OpenApiSchema>) {
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
if (key === "share" && value.properties?.url) {
|
||||
value.properties.url = nullable(value.properties.url)
|
||||
continue
|
||||
}
|
||||
if (key === "time" && value.properties) {
|
||||
makePropertiesNullable(value.properties)
|
||||
continue
|
||||
}
|
||||
properties[key] = nullable(value)
|
||||
}
|
||||
}
|
||||
|
||||
function nullable(schema: OpenApiSchema): OpenApiSchema {
|
||||
if (flattenOptions(schema.anyOf ?? schema.oneOf)?.some((item) => item.type === "null")) return schema
|
||||
return { anyOf: [schema, { type: "null" }] }
|
||||
}
|
||||
|
||||
function stableSchema(input: unknown, schemas: Record<string, OpenApiSchema>): string {
|
||||
return JSON.stringify(canonicalizeSchema(input, schemas))
|
||||
}
|
||||
|
||||
function canonicalizeSchema(input: unknown, schemas: Record<string, OpenApiSchema>): unknown {
|
||||
if (Array.isArray(input)) return input.map((item) => canonicalizeSchema(item, schemas))
|
||||
if (!input || typeof input !== "object") return input
|
||||
const schema = input as OpenApiSchema
|
||||
if (schema.$ref) return { $ref: canonicalRef(schema.$ref, schemas) }
|
||||
return Object.fromEntries(
|
||||
Object.entries(input)
|
||||
.filter(([key]) => key !== "description")
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => [key, canonicalizeSchema(value, schemas)]),
|
||||
)
|
||||
}
|
||||
|
||||
function canonicalRef(ref: string, schemas: Record<string, OpenApiSchema>) {
|
||||
const name = ref.replace("#/components/schemas/", "")
|
||||
const base = name.replace(/\d+$/, "")
|
||||
if (base !== name && schemas[base]) return `#/components/schemas/${base}`
|
||||
return ref
|
||||
}
|
||||
|
||||
function rewriteRefs(input: unknown, from: string, to: string): void {
|
||||
if (Array.isArray(input)) {
|
||||
for (const item of input) rewriteRefs(item, from, to)
|
||||
return
|
||||
}
|
||||
if (!input || typeof input !== "object") return
|
||||
const schema = input as OpenApiSchema
|
||||
if (schema.$ref === `#/components/schemas/${from}`) schema.$ref = `#/components/schemas/${to}`
|
||||
for (const value of Object.values(input)) rewriteRefs(value, from, to)
|
||||
}
|
||||
|
||||
function normalizeLegacyErrorResponses(operation: OpenApiOperation) {
|
||||
if (operation.responses?.["400"] && isBuiltInErrorResponse(operation.responses["400"], "BadRequest")) {
|
||||
operation.responses["400"] = legacyErrorResponse("Bad request", "BadRequestError")
|
||||
}
|
||||
if (operation.responses?.["404"] && isBuiltInErrorResponse(operation.responses["404"], "NotFound")) {
|
||||
operation.responses["404"] = legacyErrorResponse("Not found", "NotFoundError")
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLegacyOperation(operation: OpenApiOperation, path: string, method: string) {
|
||||
if (path === "/experimental/console/switch" && method === "post") delete operation.responses?.["400"]
|
||||
if (path === "/pty/{ptyID}" && method === "put") delete operation.responses?.["404"]
|
||||
if ((path !== "/session/{sessionID}/message" && path !== "/session/{sessionID}/command") || method !== "post") return
|
||||
const response = operation.responses?.["200"]?.content?.["application/json"]
|
||||
if (!response) return
|
||||
response.schema = {
|
||||
type: "object",
|
||||
required: ["info", "parts"],
|
||||
properties: {
|
||||
info: { $ref: "#/components/schemas/AssistantMessage" },
|
||||
parts: {
|
||||
type: "array",
|
||||
items: { $ref: "#/components/schemas/Part" },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function isRefResponse(response: OpenApiResponse, name: string) {
|
||||
return response.content?.["application/json"]?.schema?.$ref === `#/components/schemas/${name}`
|
||||
}
|
||||
|
||||
function isBuiltInErrorResponse(response: OpenApiResponse, name: "BadRequest" | "NotFound") {
|
||||
return response.description === name || isRefResponse(response, `EffectHttpApiError${name}`)
|
||||
}
|
||||
|
||||
function legacyErrorResponse(description: string, name: "BadRequestError" | "NotFoundError"): OpenApiResponse {
|
||||
return {
|
||||
description,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: `#/components/schemas/${name}` },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix component schemas that are self-referencing `$ref`s — an Effect OpenAPI
|
||||
* generation bug where annotated union arms that share AST nodes with other
|
||||
* endpoints produce `{"$ref":"#/components/schemas/X"}` as the definition of X.
|
||||
*
|
||||
* Resolves by finding the actual schema from a parent union's `anyOf`/`oneOf`
|
||||
* that references the broken component, then inlining that schema.
|
||||
*/
|
||||
function fixSelfReferencingComponents(spec: OpenApiSpec) {
|
||||
const schemas = spec.components?.schemas
|
||||
if (!schemas) return
|
||||
const selfRefs = new Set<string>()
|
||||
for (const [name, schema] of Object.entries(schemas)) {
|
||||
if (schema.$ref === `#/components/schemas/${name}`) selfRefs.add(name)
|
||||
}
|
||||
if (selfRefs.size === 0) return
|
||||
// Find a parent union component whose anyOf/oneOf contains a $ref to the
|
||||
// broken component — that parent was generated correctly and holds the inline
|
||||
// schema we need.
|
||||
for (const [, schema] of Object.entries(schemas)) {
|
||||
for (const member of schema.anyOf ?? schema.oneOf ?? []) {
|
||||
const ref = member.$ref?.replace("#/components/schemas/", "")
|
||||
if (!ref || !selfRefs.has(ref)) continue
|
||||
// This member's $ref points to a self-referencing component. The member
|
||||
// itself is just {$ref:...}, so the actual schema must be resolved from
|
||||
// the union. Since the union component was generated before the
|
||||
// deduplicator broke things, the inline version lives elsewhere. Generate
|
||||
// a fresh spec without the transform to get the correct schema.
|
||||
// Simpler approach: look through all paths for an endpoint that uses this
|
||||
// schema as a payload (it would have been expanded by the ref-expansion
|
||||
// logic above if we ran after that, but we run before). Instead, just
|
||||
// delete the broken component — if it's referenced via $ref elsewhere,
|
||||
// the ref expansion in the request body loop will inline it anyway.
|
||||
}
|
||||
}
|
||||
// Simplest fix: generate the raw spec (without transform) to get correct schemas
|
||||
const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec
|
||||
const rawSchemas = raw.components?.schemas
|
||||
if (!rawSchemas) return
|
||||
for (const name of selfRefs) {
|
||||
if (rawSchemas[name]) schemas[name] = rawSchemas[name]
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */
|
||||
function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema {
|
||||
if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} }
|
||||
const options = flattenOptions(schema.anyOf ?? schema.oneOf)
|
||||
if (options) {
|
||||
const withoutNull = options.filter((item) => item.type !== "null")
|
||||
const finite = withoutNull.find((item) => item.type === "number")
|
||||
if (finite && withoutNull.every(isFiniteNumberOption)) return { type: "number" }
|
||||
if (withoutNull.length === 1) return normalizeRequestSchema(withoutNull[0])
|
||||
if (schema.anyOf) schema.anyOf = withoutNull.map(normalizeRequestSchema)
|
||||
if (schema.oneOf) schema.oneOf = withoutNull.map(normalizeRequestSchema)
|
||||
if (withoutNull.length === 1) return stripOptionalNull(withoutNull[0])
|
||||
if (schema.anyOf) schema.anyOf = withoutNull.map(stripOptionalNull)
|
||||
if (schema.oneOf) schema.oneOf = withoutNull.map(stripOptionalNull)
|
||||
}
|
||||
if (schema.allOf) {
|
||||
if (schema.type) delete schema.allOf
|
||||
else schema.allOf = schema.allOf.map(normalizeRequestSchema)
|
||||
const allOf = schema.allOf.map(stripOptionalNull)
|
||||
if (schema.type) {
|
||||
delete schema.allOf
|
||||
for (const item of allOf) Object.assign(schema, item)
|
||||
} else {
|
||||
schema.allOf = allOf
|
||||
}
|
||||
}
|
||||
if (schema.prefixItems && schema.items) delete schema.prefixItems
|
||||
if (schema.items) schema.items = normalizeRequestSchema(schema.items)
|
||||
if (schema.items) schema.items = stripOptionalNull(schema.items)
|
||||
if (schema.properties) {
|
||||
for (const [key, value] of Object.entries(schema.properties)) {
|
||||
schema.properties[key] = normalizeRequestSchema(value)
|
||||
schema.properties[key] = stripOptionalNull(value)
|
||||
}
|
||||
}
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||
schema.additionalProperties = normalizeRequestSchema(schema.additionalProperties)
|
||||
schema.additionalProperties = stripOptionalNull(schema.additionalProperties)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined {
|
||||
return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item])
|
||||
function isEmptyObjectUnion(schema: OpenApiSchema) {
|
||||
const options = schema.anyOf ?? schema.oneOf
|
||||
return options?.length === 2 && options.some(isBareObjectSchema) && options.some(isBareArraySchema)
|
||||
}
|
||||
|
||||
function isFiniteNumberOption(schema: OpenApiSchema) {
|
||||
if (schema.type === "number") return true
|
||||
return schema.type === "string" && schema.enum?.every((value) => FiniteNumberValues.has(value)) === true
|
||||
function isBareObjectSchema(schema: OpenApiSchema) {
|
||||
return schema.type === "object" && !schema.properties && !schema.additionalProperties
|
||||
}
|
||||
|
||||
function isBareArraySchema(schema: OpenApiSchema) {
|
||||
return schema.type === "array" && !schema.items && !schema.prefixItems
|
||||
}
|
||||
|
||||
function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined {
|
||||
return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item])
|
||||
}
|
||||
|
||||
function normalizeParameter(param: OpenApiParameter, route: string) {
|
||||
@@ -205,28 +483,10 @@ function normalizeParameter(param: OpenApiParameter, route: string) {
|
||||
}
|
||||
return
|
||||
}
|
||||
param.schema = normalizeRequestSchema(param.schema)
|
||||
param.schema = stripOptionalNull(param.schema)
|
||||
}
|
||||
|
||||
export const PublicApi = HttpApi.make("opencode")
|
||||
.addHttpApi(ControlApi)
|
||||
.addHttpApi(GlobalApi)
|
||||
.addHttpApi(EventApi)
|
||||
.addHttpApi(ConfigApi)
|
||||
.addHttpApi(ExperimentalApi)
|
||||
.addHttpApi(FileApi)
|
||||
.addHttpApi(InstanceApi)
|
||||
.addHttpApi(McpApi)
|
||||
.addHttpApi(PermissionApi)
|
||||
.addHttpApi(ProjectApi)
|
||||
.addHttpApi(ProviderApi)
|
||||
.addHttpApi(PtyApi)
|
||||
.addHttpApi(PtyConnectApi)
|
||||
.addHttpApi(QuestionApi)
|
||||
.addHttpApi(SessionApi)
|
||||
.addHttpApi(SyncApi)
|
||||
.addHttpApi(TuiApi)
|
||||
.addHttpApi(WorkspaceApi)
|
||||
export const PublicApi = OpenCodeHttpApi
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode",
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { Account } from "@/account/account"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Auth } from "@/auth"
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import { Command } from "@/command"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
import { File } from "@/file"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
@@ -16,8 +14,6 @@ import { Format } from "@/format"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Permission } from "@/permission"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Installation } from "@/installation"
|
||||
import { Project } from "@/project/project"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
@@ -32,131 +28,103 @@ import { Todo } from "@/session/todo"
|
||||
import { Skill } from "@/skill"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { authorizationLayer } from "./auth"
|
||||
import { ConfigApi, configHandlers } from "./config"
|
||||
import { ControlApi, controlHandlers } from "./control"
|
||||
import { eventRoute } from "./event"
|
||||
import { FileApi, fileHandlers } from "./file"
|
||||
import { ExperimentalApi, experimentalHandlers } from "./experimental"
|
||||
import { GlobalApi, globalHandlers } from "./global"
|
||||
import { InstanceApi, instanceHandlers } from "./instance"
|
||||
import { McpApi, mcpHandlers } from "./mcp"
|
||||
import { PermissionApi, permissionHandlers } from "./permission"
|
||||
import { ProjectApi, projectHandlers } from "./project"
|
||||
import { PtyApi, ptyConnectRoute, ptyHandlers } from "./pty"
|
||||
import { ProviderApi, providerHandlers } from "./provider"
|
||||
import { QuestionApi, questionHandlers } from "./question"
|
||||
import { SessionApi, sessionHandlers } from "./session"
|
||||
import { SyncApi, syncHandlers } from "./sync"
|
||||
import { TuiApi, tuiHandlers } from "./tui"
|
||||
import { WorkspaceApi, workspaceHandlers } from "./workspace"
|
||||
import { configHandlers } from "./handlers/config"
|
||||
import { controlHandlers } from "./handlers/control"
|
||||
import { experimentalHandlers } from "./handlers/experimental"
|
||||
import { fileHandlers } from "./handlers/file"
|
||||
import { globalHandlers } from "./handlers/global"
|
||||
import { instanceHandlers } from "./handlers/instance"
|
||||
import { mcpHandlers } from "./handlers/mcp"
|
||||
import { permissionHandlers } from "./handlers/permission"
|
||||
import { projectHandlers } from "./handlers/project"
|
||||
import { providerHandlers } from "./handlers/provider"
|
||||
import { ptyConnectRoute, ptyHandlers } from "./handlers/pty"
|
||||
import { questionHandlers } from "./handlers/question"
|
||||
import { sessionHandlers } from "./handlers/session"
|
||||
import { syncHandlers } from "./handlers/sync"
|
||||
import { tuiHandlers } from "./handlers/tui"
|
||||
import { workspaceHandlers } from "./handlers/workspace"
|
||||
import { instanceContextLayer, instanceRouterLayer } from "./instance-context"
|
||||
import { disposeMiddleware } from "./lifecycle"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
|
||||
const Query = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
workspace: Schema.optional(Schema.String),
|
||||
auth_token: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const Headers = Schema.Struct({
|
||||
authorization: Schema.optional(Schema.String),
|
||||
"x-opencode-directory": Schema.optional(Schema.String),
|
||||
})
|
||||
import * as ServerBackend from "@/server/backend"
|
||||
|
||||
export const context = Context.empty() as Context.Context<unknown>
|
||||
|
||||
function decode(input: string) {
|
||||
try {
|
||||
return decodeURIComponent(input)
|
||||
} catch {
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
const instance = HttpRouter.middleware()(
|
||||
Effect.gen(function* () {
|
||||
return (effect) =>
|
||||
Effect.gen(function* () {
|
||||
const query = yield* HttpServerRequest.schemaSearchParams(Query)
|
||||
const headers = yield* HttpServerRequest.schemaHeaders(Headers)
|
||||
const raw = query.directory || headers["x-opencode-directory"] || process.cwd()
|
||||
const workspace = query.workspace || undefined
|
||||
const ctx = yield* Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: Filesystem.resolve(decode(raw)),
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
fn: () => Instance.current,
|
||||
}),
|
||||
)
|
||||
|
||||
const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect
|
||||
return yield* next.pipe(Effect.provideService(InstanceRef, ctx))
|
||||
})
|
||||
}),
|
||||
const runtime = HttpRouter.middleware()(
|
||||
Effect.succeed((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const selected = ServerBackend.select()
|
||||
yield* Effect.annotateCurrentSpan(ServerBackend.attributes(ServerBackend.force(selected, "effect-httpapi")))
|
||||
return yield* effect
|
||||
}),
|
||||
),
|
||||
).layer
|
||||
|
||||
const controlRoutes = HttpApiBuilder.layer(ControlApi).pipe(Layer.provide(controlHandlers))
|
||||
const globalRoutes = HttpApiBuilder.layer(GlobalApi).pipe(Layer.provide(globalHandlers))
|
||||
const instanceApiRoutes = Layer.mergeAll(
|
||||
HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)),
|
||||
HttpApiBuilder.layer(ExperimentalApi).pipe(Layer.provide(experimentalHandlers)),
|
||||
HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)),
|
||||
HttpApiBuilder.layer(InstanceApi).pipe(Layer.provide(instanceHandlers)),
|
||||
HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)),
|
||||
HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)),
|
||||
HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers)),
|
||||
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
|
||||
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
|
||||
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
|
||||
HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
|
||||
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
|
||||
HttpApiBuilder.layer(TuiApi).pipe(Layer.provide(tuiHandlers)),
|
||||
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
|
||||
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers]))
|
||||
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
Layer.provide([
|
||||
configHandlers,
|
||||
experimentalHandlers,
|
||||
fileHandlers,
|
||||
instanceHandlers,
|
||||
mcpHandlers,
|
||||
projectHandlers,
|
||||
ptyHandlers,
|
||||
questionHandlers,
|
||||
permissionHandlers,
|
||||
providerHandlers,
|
||||
sessionHandlers,
|
||||
syncHandlers,
|
||||
tuiHandlers,
|
||||
workspaceHandlers,
|
||||
]),
|
||||
)
|
||||
|
||||
const instanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute, instanceApiRoutes).pipe(
|
||||
Layer.provide(authorizationLayer),
|
||||
Layer.provide(instance),
|
||||
const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
|
||||
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
|
||||
Layer.provide([authorizationLayer, instanceContextLayer]),
|
||||
)
|
||||
|
||||
export const routes = Layer.mergeAll(controlRoutes, globalRoutes, instanceRoutes)
|
||||
.pipe(
|
||||
Layer.provide(Account.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Command.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(File.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(Installation.defaultLayer),
|
||||
Layer.provide(MCP.defaultLayer),
|
||||
Layer.provide(Permission.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(ProviderAuth.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Pty.defaultLayer),
|
||||
Layer.provide(Question.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
)
|
||||
.pipe(
|
||||
Layer.provide(SessionRunState.defaultLayer),
|
||||
Layer.provide(SessionStatus.defaultLayer),
|
||||
Layer.provide(SessionSummary.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(Todo.defaultLayer),
|
||||
Layer.provide(ToolRegistry.defaultLayer),
|
||||
Layer.provide(Vcs.defaultLayer),
|
||||
Layer.provide(Worktree.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
Layer.provideMerge(Observability.layer),
|
||||
)
|
||||
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
|
||||
Layer.provide([
|
||||
runtime,
|
||||
Account.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
Command.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
File.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
MCP.defaultLayer,
|
||||
Permission.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
ProviderAuth.defaultLayer,
|
||||
Provider.defaultLayer,
|
||||
Pty.defaultLayer,
|
||||
Question.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
SessionRunState.defaultLayer,
|
||||
SessionStatus.defaultLayer,
|
||||
SessionSummary.defaultLayer,
|
||||
Skill.defaultLayer,
|
||||
Todo.defaultLayer,
|
||||
ToolRegistry.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Worktree.defaultLayer,
|
||||
Bus.layer,
|
||||
HttpServer.layerServices,
|
||||
]),
|
||||
Layer.provideMerge(Observability.layer),
|
||||
)
|
||||
|
||||
export const webHandler = lazy(() =>
|
||||
HttpRouter.toWebHandler(routes, {
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { startWorkspaceSyncing } from "@/control-plane/workspace"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Database } from "@/storage/db"
|
||||
import { asc } from "drizzle-orm"
|
||||
import { and } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { lte } from "drizzle-orm"
|
||||
import { not } from "drizzle-orm"
|
||||
import { or } from "drizzle-orm"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { EventTable } from "@/sync/event.sql"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
|
||||
const root = "/sync"
|
||||
const ReplayEvent = Schema.Struct({
|
||||
id: Schema.String,
|
||||
aggregateID: Schema.String,
|
||||
seq: NonNegativeInt,
|
||||
type: Schema.String,
|
||||
data: Schema.Record(Schema.String, Schema.Unknown),
|
||||
}).annotate({ identifier: "SyncReplayEvent" })
|
||||
const ReplayPayload = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
events: Schema.NonEmptyArray(ReplayEvent),
|
||||
}).annotate({ identifier: "SyncReplayInput" })
|
||||
const ReplayResponse = Schema.Struct({
|
||||
sessionID: Schema.String,
|
||||
}).annotate({ identifier: "SyncReplayResponse" })
|
||||
const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
|
||||
const HistoryEvent = Schema.Struct({
|
||||
id: Schema.String,
|
||||
aggregate_id: Schema.String,
|
||||
seq: Schema.Number,
|
||||
type: Schema.String,
|
||||
data: Schema.Record(Schema.String, Schema.Unknown),
|
||||
}).annotate({ identifier: "SyncHistoryEvent" })
|
||||
|
||||
export const SyncPaths = {
|
||||
start: `${root}/start`,
|
||||
replay: `${root}/replay`,
|
||||
history: `${root}/history`,
|
||||
} as const
|
||||
|
||||
export const SyncApi = HttpApi.make("sync")
|
||||
.add(
|
||||
HttpApiGroup.make("sync")
|
||||
.add(
|
||||
HttpApiEndpoint.post("start", SyncPaths.start, {
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "sync.start",
|
||||
summary: "Start workspace sync",
|
||||
description: "Start sync loops for workspaces in the current project that have active sessions.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("replay", SyncPaths.replay, {
|
||||
payload: ReplayPayload,
|
||||
success: ReplayResponse,
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "sync.replay",
|
||||
summary: "Replay sync events",
|
||||
description: "Validate and replay a complete sync event history.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("history", SyncPaths.history, {
|
||||
payload: HistoryPayload,
|
||||
success: Schema.Array(HistoryEvent),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "sync.history.list",
|
||||
summary: "List sync events",
|
||||
description:
|
||||
"List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "sync",
|
||||
description: "Experimental HttpApi sync routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const syncHandlers = HttpApiBuilder.group(SyncApi, "sync", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const start = Effect.fn("SyncHttpApi.start")(function* () {
|
||||
startWorkspaceSyncing((yield* InstanceState.context).project.id)
|
||||
return true
|
||||
})
|
||||
|
||||
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
|
||||
const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({
|
||||
id: event.id,
|
||||
aggregateID: event.aggregateID,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: { ...event.data },
|
||||
}))
|
||||
SyncEvent.replayAll(events)
|
||||
return { sessionID: events[0].aggregateID }
|
||||
})
|
||||
|
||||
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
|
||||
const exclude = Object.entries(ctx.payload)
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(
|
||||
exclude.length > 0
|
||||
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
|
||||
: undefined,
|
||||
)
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all(),
|
||||
)
|
||||
})
|
||||
|
||||
return handlers.handle("start", start).handle("replay", replay).handle("history", history)
|
||||
}),
|
||||
)
|
||||
@@ -1,291 +0,0 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import * as Database from "@/storage/db"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { nextTuiRequest, submitTuiResponse } from "../tui"
|
||||
import { Authorization } from "./auth"
|
||||
|
||||
const root = "/tui"
|
||||
const CommandPayload = Schema.Struct({ command: Schema.String }).annotate({ identifier: "TuiCommandInput" })
|
||||
const TuiRequestPayload = Schema.Struct({
|
||||
path: Schema.String,
|
||||
body: Schema.Unknown,
|
||||
}).annotate({ identifier: "TuiRequest" })
|
||||
const TuiPublishPayload = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }),
|
||||
Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }),
|
||||
Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }),
|
||||
Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }),
|
||||
]).annotate({ identifier: "TuiEventInput" })
|
||||
|
||||
const commandAliases = {
|
||||
session_new: "session.new",
|
||||
session_share: "session.share",
|
||||
session_interrupt: "session.interrupt",
|
||||
session_compact: "session.compact",
|
||||
messages_page_up: "session.page.up",
|
||||
messages_page_down: "session.page.down",
|
||||
messages_line_up: "session.line.up",
|
||||
messages_line_down: "session.line.down",
|
||||
messages_half_page_up: "session.half.page.up",
|
||||
messages_half_page_down: "session.half.page.down",
|
||||
messages_first: "session.first",
|
||||
messages_last: "session.last",
|
||||
agent_cycle: "agent.cycle",
|
||||
} as const
|
||||
|
||||
export const TuiPaths = {
|
||||
appendPrompt: `${root}/append-prompt`,
|
||||
openHelp: `${root}/open-help`,
|
||||
openSessions: `${root}/open-sessions`,
|
||||
openThemes: `${root}/open-themes`,
|
||||
openModels: `${root}/open-models`,
|
||||
submitPrompt: `${root}/submit-prompt`,
|
||||
clearPrompt: `${root}/clear-prompt`,
|
||||
executeCommand: `${root}/execute-command`,
|
||||
showToast: `${root}/show-toast`,
|
||||
publish: `${root}/publish`,
|
||||
selectSession: `${root}/select-session`,
|
||||
controlNext: `${root}/control/next`,
|
||||
controlResponse: `${root}/control/response`,
|
||||
} as const
|
||||
|
||||
export const TuiApi = HttpApi.make("tui")
|
||||
.add(
|
||||
HttpApiGroup.make("tui")
|
||||
.add(
|
||||
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
|
||||
payload: TuiEvent.PromptAppend.properties,
|
||||
success: Schema.Boolean,
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.appendPrompt",
|
||||
summary: "Append TUI prompt",
|
||||
description: "Append prompt to the TUI.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: Schema.Boolean }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.openHelp",
|
||||
summary: "Open help dialog",
|
||||
description: "Open the help dialog in the TUI to display user assistance information.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: Schema.Boolean }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.openSessions",
|
||||
summary: "Open sessions dialog",
|
||||
description: "Open the session dialog.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: Schema.Boolean }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.openThemes",
|
||||
summary: "Open themes dialog",
|
||||
description: "Open the theme dialog.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: Schema.Boolean }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.openModels",
|
||||
summary: "Open models dialog",
|
||||
description: "Open the model dialog.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: Schema.Boolean }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.submitPrompt",
|
||||
summary: "Submit TUI prompt",
|
||||
description: "Submit the prompt.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: Schema.Boolean }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.clearPrompt",
|
||||
summary: "Clear TUI prompt",
|
||||
description: "Clear the prompt.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
|
||||
payload: CommandPayload,
|
||||
success: Schema.Boolean,
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.executeCommand",
|
||||
summary: "Execute TUI command",
|
||||
description: "Execute a TUI command.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
|
||||
payload: TuiEvent.ToastShow.properties,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.showToast",
|
||||
summary: "Show TUI toast",
|
||||
description: "Show a toast notification in the TUI.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("publish", TuiPaths.publish, {
|
||||
payload: TuiPublishPayload,
|
||||
success: Schema.Boolean,
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.publish",
|
||||
summary: "Publish TUI event",
|
||||
description: "Publish a TUI event.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
|
||||
payload: TuiEvent.SessionSelect.properties,
|
||||
success: Schema.Boolean,
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.selectSession",
|
||||
summary: "Select session",
|
||||
description: "Navigate the TUI to display the specified session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: TuiRequestPayload }).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.control.next",
|
||||
summary: "Get next TUI request",
|
||||
description: "Retrieve the next TUI request from the queue for processing.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, {
|
||||
payload: Schema.Unknown,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "tui.control.response",
|
||||
summary: "Submit TUI response",
|
||||
description: "Submit a response to the TUI request queue to complete a pending request.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." }))
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const tuiHandlers = HttpApiBuilder.group(TuiApi, "tui", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
|
||||
bus.publish(TuiEvent.CommandExecute, { command })
|
||||
|
||||
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
|
||||
payload: typeof TuiEvent.PromptAppend.properties.Type
|
||||
}) {
|
||||
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () {
|
||||
yield* publishCommand("help.show")
|
||||
return true
|
||||
})
|
||||
|
||||
const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () {
|
||||
yield* publishCommand("session.list")
|
||||
return true
|
||||
})
|
||||
|
||||
const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () {
|
||||
yield* publishCommand("session.list")
|
||||
return true
|
||||
})
|
||||
|
||||
const openModels = Effect.fn("TuiHttpApi.openModels")(function* () {
|
||||
yield* publishCommand("model.list")
|
||||
return true
|
||||
})
|
||||
|
||||
const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () {
|
||||
yield* publishCommand("prompt.submit")
|
||||
return true
|
||||
})
|
||||
|
||||
const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () {
|
||||
yield* publishCommand("prompt.clear")
|
||||
return true
|
||||
})
|
||||
|
||||
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
|
||||
payload: typeof CommandPayload.Type
|
||||
}) {
|
||||
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
|
||||
return true
|
||||
})
|
||||
|
||||
const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: {
|
||||
payload: typeof TuiEvent.ToastShow.properties.Type
|
||||
}) {
|
||||
yield* bus.publish(TuiEvent.ToastShow, ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) {
|
||||
if (ctx.payload.type === TuiEvent.PromptAppend.type)
|
||||
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties)
|
||||
if (ctx.payload.type === TuiEvent.CommandExecute.type)
|
||||
yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties)
|
||||
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties)
|
||||
if (ctx.payload.type === TuiEvent.SessionSelect.type)
|
||||
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties)
|
||||
return true
|
||||
})
|
||||
|
||||
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
|
||||
payload: typeof TuiEvent.SessionSelect.properties.Type
|
||||
}) {
|
||||
const row = yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(),
|
||||
),
|
||||
)
|
||||
if (!row) return yield* new HttpApiError.NotFound({})
|
||||
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () {
|
||||
return yield* Effect.promise(() => nextTuiRequest())
|
||||
})
|
||||
|
||||
const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) {
|
||||
submitTuiResponse(ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("appendPrompt", appendPrompt)
|
||||
.handle("openHelp", openHelp)
|
||||
.handle("openSessions", openSessions)
|
||||
.handle("openThemes", openThemes)
|
||||
.handle("openModels", openModels)
|
||||
.handle("submitPrompt", submitPrompt)
|
||||
.handle("clearPrompt", clearPrompt)
|
||||
.handle("executeCommand", executeCommand)
|
||||
.handle("showToast", showToast)
|
||||
.handle("publish", publish)
|
||||
.handle("selectSession", selectSession)
|
||||
.handle("controlNext", controlNext)
|
||||
.handle("controlResponse", controlResponse)
|
||||
}),
|
||||
)
|
||||
@@ -1,166 +0,0 @@
|
||||
import { listAdaptors } from "@/control-plane/adaptors"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Effect, Schema, Struct } from "effect"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "./auth"
|
||||
|
||||
const root = "/experimental/workspace"
|
||||
const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])).annotate({
|
||||
identifier: "WorkspaceCreateInput",
|
||||
})
|
||||
const SessionRestorePayload = Schema.Struct(
|
||||
Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]),
|
||||
).annotate({
|
||||
identifier: "WorkspaceSessionRestoreInput",
|
||||
})
|
||||
const SessionRestoreResponse = Schema.Struct({
|
||||
total: Schema.Number,
|
||||
}).annotate({ identifier: "WorkspaceSessionRestoreResponse" })
|
||||
|
||||
export const WorkspacePaths = {
|
||||
adaptors: `${root}/adaptor`,
|
||||
list: root,
|
||||
status: `${root}/status`,
|
||||
remove: `${root}/:id`,
|
||||
sessionRestore: `${root}/:id/session-restore`,
|
||||
} as const
|
||||
|
||||
export const WorkspaceApi = HttpApi.make("workspace")
|
||||
.add(
|
||||
HttpApiGroup.make("workspace")
|
||||
.add(
|
||||
HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, {
|
||||
success: Schema.Array(WorkspaceAdaptorEntry),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.adaptor.list",
|
||||
summary: "List workspace adaptors",
|
||||
description: "List all available workspace adaptors for the current project.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("list", WorkspacePaths.list, {
|
||||
success: Schema.Array(Workspace.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.list",
|
||||
summary: "List workspaces",
|
||||
description: "List all workspaces.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", WorkspacePaths.list, {
|
||||
payload: CreatePayload,
|
||||
success: Workspace.Info,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.create",
|
||||
summary: "Create workspace",
|
||||
description: "Create a workspace for the current project.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", WorkspacePaths.status, {
|
||||
success: Schema.Array(Workspace.ConnectionStatus),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.status",
|
||||
summary: "Workspace status",
|
||||
description: "Get connection status for workspaces in the current project.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", WorkspacePaths.remove, {
|
||||
params: { id: Workspace.Info.fields.id },
|
||||
success: Schema.UndefinedOr(Workspace.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.remove",
|
||||
summary: "Remove workspace",
|
||||
description: "Remove an existing workspace.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
|
||||
params: { id: Workspace.Info.fields.id },
|
||||
payload: SessionRestorePayload,
|
||||
success: SessionRestoreResponse,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.sessionRestore",
|
||||
summary: "Restore session into workspace",
|
||||
description: "Replay a session's sync events into the target workspace in batches.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "workspace",
|
||||
description: "Experimental HttpApi workspace routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
export const workspaceHandlers = HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
return yield* Effect.promise(() => listAdaptors(ctx.project.id))
|
||||
})
|
||||
|
||||
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
|
||||
return Workspace.list((yield* InstanceState.context).project)
|
||||
})
|
||||
|
||||
const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
Workspace.create({
|
||||
...ctx.payload,
|
||||
projectID: instance.project.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
|
||||
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
|
||||
return Workspace.status().filter((item) => ids.has(item.workspaceID))
|
||||
})
|
||||
|
||||
const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
|
||||
})
|
||||
|
||||
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
|
||||
params: { id: Workspace.Info["id"] }
|
||||
payload: typeof SessionRestorePayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() =>
|
||||
Instance.restore(instance, () =>
|
||||
Workspace.sessionRestore({
|
||||
workspaceID: ctx.params.id,
|
||||
sessionID: ctx.payload.sessionID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("adaptors", adaptors)
|
||||
.handle("list", list)
|
||||
.handle("create", create)
|
||||
.handle("status", status)
|
||||
.handle("remove", remove)
|
||||
.handle("sessionRestore", sessionRestore)
|
||||
}),
|
||||
)
|
||||
@@ -17,6 +17,7 @@ import { WorkspaceRouterMiddleware } from "./workspace"
|
||||
import { InstanceMiddleware } from "./routes/instance/middleware"
|
||||
import { WorkspaceRoutes } from "./routes/control/workspace"
|
||||
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
|
||||
import * as ServerBackend from "./backend"
|
||||
|
||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -37,13 +38,38 @@ type ServerApp = {
|
||||
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
|
||||
}
|
||||
|
||||
const DefaultHono = lazy(() => createHono({}))
|
||||
const DefaultHttpApi = lazy(() => createHttpApi())
|
||||
export const Default = () => (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI ? DefaultHttpApi() : DefaultHono())
|
||||
const DefaultHono = lazy(() => withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })))
|
||||
const DefaultHttpApi = lazy(() => createDefaultHttpApi())
|
||||
|
||||
function select() {
|
||||
return ServerBackend.select()
|
||||
}
|
||||
|
||||
export const backend = select
|
||||
|
||||
export const Default = () => {
|
||||
const selected = select()
|
||||
return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono()
|
||||
}
|
||||
|
||||
function create(opts: { cors?: string[] }) {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return createHttpApi()
|
||||
return createHono(opts)
|
||||
const selected = select()
|
||||
return selected.backend === "effect-httpapi"
|
||||
? withBackend(selected, createHttpApi())
|
||||
: withBackend(selected, createHono(opts, selected))
|
||||
}
|
||||
|
||||
export function Legacy(opts: { cors?: string[] } = {}) {
|
||||
return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" }))
|
||||
}
|
||||
|
||||
function createDefaultHttpApi() {
|
||||
return withBackend(select(), createHttpApi())
|
||||
}
|
||||
|
||||
function withBackend<T extends { app: ServerApp; runtime: unknown }>(selection: ServerBackend.Selection, built: T) {
|
||||
log.info("server backend selected", ServerBackend.attributes(selection))
|
||||
return built
|
||||
}
|
||||
|
||||
function createHttpApi() {
|
||||
@@ -60,11 +86,12 @@ function createHttpApi() {
|
||||
}
|
||||
}
|
||||
|
||||
function createHono(opts: { cors?: string[] }) {
|
||||
function createHono(opts: { cors?: string[] }, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) {
|
||||
const backendAttributes = ServerBackend.attributes(selection)
|
||||
const app = new Hono()
|
||||
.onError(ErrorMiddleware)
|
||||
.use(AuthMiddleware)
|
||||
.use(LoggerMiddleware)
|
||||
.use(LoggerMiddleware(backendAttributes))
|
||||
.use(CompressionMiddleware)
|
||||
.use(CorsMiddleware(opts))
|
||||
.route("/global", GlobalRoutes())
|
||||
|
||||
@@ -21,7 +21,7 @@ const RULES: Array<Rule> = [
|
||||
{ method: "GET", path: "/session", action: "local" },
|
||||
]
|
||||
|
||||
function local(method: string, path: string) {
|
||||
export function isLocalWorkspaceRoute(method: string, path: string) {
|
||||
for (const rule of RULES) {
|
||||
if (rule.method && rule.method !== method) continue
|
||||
const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
|
||||
@@ -30,7 +30,7 @@ function local(method: string, path: string) {
|
||||
return false
|
||||
}
|
||||
|
||||
function getSessionID(url: URL) {
|
||||
export function getWorkspaceRouteSessionID(url: URL) {
|
||||
if (url.pathname === "/session/status") return null
|
||||
|
||||
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
|
||||
@@ -39,8 +39,17 @@ function getSessionID(url: URL) {
|
||||
return SessionID.make(id)
|
||||
}
|
||||
|
||||
export function workspaceProxyURL(target: string | URL, requestURL: URL) {
|
||||
const proxyURL = new URL(target)
|
||||
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}`
|
||||
proxyURL.search = requestURL.search
|
||||
proxyURL.hash = requestURL.hash
|
||||
proxyURL.searchParams.delete("workspace")
|
||||
return proxyURL
|
||||
}
|
||||
|
||||
async function getSessionWorkspace(url: URL) {
|
||||
const id = getSessionID(url)
|
||||
const id = getWorkspaceRouteSessionID(url)
|
||||
if (!id) return null
|
||||
|
||||
const session = await AppRuntime.runPromise(
|
||||
@@ -73,7 +82,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
})
|
||||
}
|
||||
|
||||
if (local(c.req.method, url.pathname)) {
|
||||
if (isLocalWorkspaceRoute(c.req.method, url.pathname)) {
|
||||
// No instance provided because we are serving cached data; there
|
||||
// is no instance to work with
|
||||
return next()
|
||||
@@ -96,11 +105,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
})
|
||||
}
|
||||
|
||||
const proxyURL = new URL(target.url)
|
||||
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${url.pathname}`
|
||||
proxyURL.search = url.search
|
||||
proxyURL.hash = url.hash
|
||||
proxyURL.searchParams.delete("workspace")
|
||||
const proxyURL = workspaceProxyURL(target.url, url)
|
||||
|
||||
log.info("workspace proxy forwarding", {
|
||||
workspaceID,
|
||||
|
||||
@@ -42,7 +42,7 @@ export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}
|
||||
export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String })
|
||||
export const StructuredOutputError = namedSchemaError("StructuredOutputError", {
|
||||
message: Schema.String,
|
||||
retries: Schema.Number,
|
||||
retries: NonNegativeInt,
|
||||
})
|
||||
export const AuthError = namedSchemaError("ProviderAuthError", {
|
||||
providerID: Schema.String,
|
||||
@@ -50,7 +50,7 @@ export const AuthError = namedSchemaError("ProviderAuthError", {
|
||||
})
|
||||
export const APIError = namedSchemaError("APIError", {
|
||||
message: Schema.String,
|
||||
statusCode: Schema.optional(Schema.Number),
|
||||
statusCode: Schema.optional(NonNegativeInt),
|
||||
isRetryable: Schema.Boolean,
|
||||
responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
responseBody: Schema.optional(Schema.String),
|
||||
@@ -116,8 +116,8 @@ export const TextPart = Schema.Struct({
|
||||
ignored: Schema.optional(Schema.Boolean),
|
||||
time: Schema.optional(
|
||||
Schema.Struct({
|
||||
start: Schema.Number,
|
||||
end: Schema.optional(Schema.Number),
|
||||
start: NonNegativeInt,
|
||||
end: Schema.optional(NonNegativeInt),
|
||||
}),
|
||||
),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||
@@ -132,8 +132,8 @@ export const ReasoningPart = Schema.Struct({
|
||||
text: Schema.String,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||
time: Schema.Struct({
|
||||
start: Schema.Number,
|
||||
end: Schema.optional(Schema.Number),
|
||||
start: NonNegativeInt,
|
||||
end: Schema.optional(NonNegativeInt),
|
||||
}),
|
||||
})
|
||||
.annotate({ identifier: "ReasoningPart" })
|
||||
@@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable<Schema.Schema.Type<typeof Reasonin
|
||||
const filePartSourceBase = {
|
||||
text: Schema.Struct({
|
||||
value: Schema.String,
|
||||
start: Schema.Int,
|
||||
end: Schema.Int,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}).annotate({ identifier: "FilePartSourceText" }),
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ export const SymbolSource = Schema.Struct({
|
||||
path: Schema.String,
|
||||
range: LSP.Range,
|
||||
name: Schema.String,
|
||||
kind: Schema.Int,
|
||||
kind: NonNegativeInt,
|
||||
})
|
||||
.annotate({ identifier: "SymbolSource" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
@@ -201,8 +201,8 @@ export const AgentPart = Schema.Struct({
|
||||
source: Schema.optional(
|
||||
Schema.Struct({
|
||||
value: Schema.String,
|
||||
start: Schema.Int,
|
||||
end: Schema.Int,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}),
|
||||
),
|
||||
})
|
||||
@@ -242,11 +242,10 @@ export type SubtaskPart = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPar
|
||||
export const RetryPart = Schema.Struct({
|
||||
...partBase,
|
||||
type: Schema.Literal("retry"),
|
||||
attempt: Schema.Number,
|
||||
// APIError is still NamedError-based Zod; bridge via ZodOverride until errors migrate.
|
||||
error: Schema.Any.annotate({ [ZodOverride]: APIError.Schema }),
|
||||
attempt: NonNegativeInt,
|
||||
error: APIError.EffectSchema,
|
||||
time: Schema.Struct({
|
||||
created: Schema.Number,
|
||||
created: NonNegativeInt,
|
||||
}),
|
||||
})
|
||||
.annotate({ identifier: "RetryPart" })
|
||||
@@ -269,15 +268,15 @@ export const StepFinishPart = Schema.Struct({
|
||||
type: Schema.Literal("step-finish"),
|
||||
reason: Schema.String,
|
||||
snapshot: Schema.optional(Schema.String),
|
||||
cost: Schema.Number,
|
||||
cost: Schema.Finite,
|
||||
tokens: Schema.Struct({
|
||||
total: Schema.optional(Schema.Number),
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
reasoning: Schema.Number,
|
||||
total: Schema.optional(NonNegativeInt),
|
||||
input: NonNegativeInt,
|
||||
output: NonNegativeInt,
|
||||
reasoning: NonNegativeInt,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
read: NonNegativeInt,
|
||||
write: NonNegativeInt,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
@@ -300,7 +299,7 @@ export const ToolStateRunning = Schema.Struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||
time: Schema.Struct({
|
||||
start: Schema.Number,
|
||||
start: NonNegativeInt,
|
||||
}),
|
||||
})
|
||||
.annotate({ identifier: "ToolStateRunning" })
|
||||
@@ -314,9 +313,9 @@ export const ToolStateCompleted = Schema.Struct({
|
||||
title: Schema.String,
|
||||
metadata: Schema.Record(Schema.String, Schema.Any),
|
||||
time: Schema.Struct({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
compacted: Schema.optional(Schema.Number),
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
compacted: Schema.optional(NonNegativeInt),
|
||||
}),
|
||||
attachments: Schema.optional(Schema.Array(FilePart)),
|
||||
})
|
||||
@@ -336,8 +335,8 @@ export const ToolStateError = Schema.Struct({
|
||||
error: Schema.String,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||
time: Schema.Struct({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}),
|
||||
})
|
||||
.annotate({ identifier: "ToolStateError" })
|
||||
@@ -380,7 +379,7 @@ export const User = Schema.Struct({
|
||||
...messageBase,
|
||||
role: Schema.Literal("user"),
|
||||
time: Schema.Struct({
|
||||
created: Schema.Number,
|
||||
created: NonNegativeInt,
|
||||
}),
|
||||
format: Schema.optional(_Format),
|
||||
summary: Schema.optional(
|
||||
@@ -447,9 +446,7 @@ export type Part =
|
||||
| RetryPart
|
||||
| CompactionPart
|
||||
|
||||
// Errors are still NamedError-based Zod; bridge via ZodOverride so the derived
|
||||
// Zod + JSON Schema emit the original discriminatedUnion shape. Migrating the
|
||||
// error classes to Schema.TaggedErrorClass is a separate slice.
|
||||
// Zod discriminated union kept for the legacy Hono OpenAPI path.
|
||||
const AssistantErrorZod = z.discriminatedUnion("name", [
|
||||
AuthError.Schema,
|
||||
NamedError.Unknown.Schema,
|
||||
@@ -461,6 +458,17 @@ const AssistantErrorZod = z.discriminatedUnion("name", [
|
||||
])
|
||||
type AssistantError = z.infer<typeof AssistantErrorZod>
|
||||
|
||||
// Effect Schema for the same union — used by HttpApi OpenAPI generation.
|
||||
const AssistantErrorSchema = Schema.Union([
|
||||
AuthError.EffectSchema,
|
||||
Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({ identifier: "UnknownError" }),
|
||||
OutputLengthError.EffectSchema,
|
||||
AbortedError.EffectSchema,
|
||||
StructuredOutputError.EffectSchema,
|
||||
ContextOverflowError.EffectSchema,
|
||||
APIError.EffectSchema,
|
||||
]).annotate({ discriminator: "name" })
|
||||
|
||||
// ── Prompt input schemas ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the
|
||||
@@ -477,8 +485,8 @@ export const TextPartInput = Schema.Struct({
|
||||
ignored: Schema.optional(Schema.Boolean),
|
||||
time: Schema.optional(
|
||||
Schema.Struct({
|
||||
start: Schema.Number,
|
||||
end: Schema.optional(Schema.Number),
|
||||
start: NonNegativeInt,
|
||||
end: Schema.optional(NonNegativeInt),
|
||||
}),
|
||||
),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||
@@ -506,8 +514,8 @@ export const AgentPartInput = Schema.Struct({
|
||||
source: Schema.optional(
|
||||
Schema.Struct({
|
||||
value: Schema.String,
|
||||
start: Schema.Int,
|
||||
end: Schema.Int,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}),
|
||||
),
|
||||
})
|
||||
@@ -537,10 +545,10 @@ export const Assistant = Schema.Struct({
|
||||
...messageBase,
|
||||
role: Schema.Literal("assistant"),
|
||||
time: Schema.Struct({
|
||||
created: Schema.Number,
|
||||
completed: Schema.optional(Schema.Number),
|
||||
created: NonNegativeInt,
|
||||
completed: Schema.optional(NonNegativeInt),
|
||||
}),
|
||||
error: Schema.optional(Schema.Any.annotate({ [ZodOverride]: AssistantErrorZod })),
|
||||
error: Schema.optional(AssistantErrorSchema),
|
||||
parentID: MessageID,
|
||||
modelID: ModelID,
|
||||
providerID: ProviderID,
|
||||
@@ -554,15 +562,15 @@ export const Assistant = Schema.Struct({
|
||||
root: Schema.String,
|
||||
}),
|
||||
summary: Schema.optional(Schema.Boolean),
|
||||
cost: Schema.Number,
|
||||
cost: Schema.Finite,
|
||||
tokens: Schema.Struct({
|
||||
total: Schema.optional(Schema.Number),
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
reasoning: Schema.Number,
|
||||
total: Schema.optional(NonNegativeInt),
|
||||
input: NonNegativeInt,
|
||||
output: NonNegativeInt,
|
||||
reasoning: NonNegativeInt,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
read: NonNegativeInt,
|
||||
write: NonNegativeInt,
|
||||
}),
|
||||
}),
|
||||
structured: Schema.optional(Schema.Any),
|
||||
@@ -594,7 +602,7 @@ const RemovedEventSchema = Schema.Struct({
|
||||
const PartUpdatedEventSchema = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
part: _Part,
|
||||
time: Schema.Number,
|
||||
time: NonNegativeInt,
|
||||
})
|
||||
|
||||
const PartRemovedEventSchema = Schema.Struct({
|
||||
@@ -651,7 +659,7 @@ export type WithParts = {
|
||||
|
||||
const Cursor = Schema.Struct({
|
||||
id: MessageID,
|
||||
time: Schema.Number,
|
||||
time: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
})
|
||||
type Cursor = typeof Cursor.Type
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Schema } from "effect"
|
||||
import { SessionID } from "./schema"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
|
||||
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
|
||||
@@ -33,7 +33,7 @@ const UnknownErrorEffect = Schema.Struct({
|
||||
|
||||
export const ToolCall = Schema.Struct({
|
||||
state: Schema.Literal("call"),
|
||||
step: Schema.optional(Schema.Number),
|
||||
step: Schema.optional(NonNegativeInt),
|
||||
toolCallId: Schema.String,
|
||||
toolName: Schema.String,
|
||||
args: Schema.Unknown,
|
||||
@@ -44,7 +44,7 @@ export type ToolCall = Schema.Schema.Type<typeof ToolCall>
|
||||
|
||||
export const ToolPartialCall = Schema.Struct({
|
||||
state: Schema.Literal("partial-call"),
|
||||
step: Schema.optional(Schema.Number),
|
||||
step: Schema.optional(NonNegativeInt),
|
||||
toolCallId: Schema.String,
|
||||
toolName: Schema.String,
|
||||
args: Schema.Unknown,
|
||||
@@ -55,7 +55,7 @@ export type ToolPartialCall = Schema.Schema.Type<typeof ToolPartialCall>
|
||||
|
||||
export const ToolResult = Schema.Struct({
|
||||
state: Schema.Literal("result"),
|
||||
step: Schema.optional(Schema.Number),
|
||||
step: Schema.optional(NonNegativeInt),
|
||||
toolCallId: Schema.String,
|
||||
toolName: Schema.String,
|
||||
args: Schema.Unknown,
|
||||
@@ -141,8 +141,8 @@ export const Info = Schema.Struct({
|
||||
parts: Schema.Array(MessagePart),
|
||||
metadata: Schema.Struct({
|
||||
time: Schema.Struct({
|
||||
created: Schema.Number,
|
||||
completed: Schema.optional(Schema.Number),
|
||||
created: NonNegativeInt,
|
||||
completed: Schema.optional(NonNegativeInt),
|
||||
}),
|
||||
error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])),
|
||||
sessionID: SessionID,
|
||||
@@ -153,8 +153,8 @@ export const Info = Schema.Struct({
|
||||
title: Schema.String,
|
||||
snapshot: Schema.optional(Schema.String),
|
||||
time: Schema.Struct({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
@@ -169,15 +169,15 @@ export const Info = Schema.Struct({
|
||||
cwd: Schema.String,
|
||||
root: Schema.String,
|
||||
}),
|
||||
cost: Schema.Number,
|
||||
cost: Schema.Finite,
|
||||
summary: Schema.optional(Schema.Boolean),
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
reasoning: Schema.Number,
|
||||
input: NonNegativeInt,
|
||||
output: NonNegativeInt,
|
||||
reasoning: NonNegativeInt,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
read: NonNegativeInt,
|
||||
write: NonNegativeInt,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -38,7 +38,7 @@ import { Permission } from "@/permission"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect, Layer, Option, Context, Schema, Types } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "session" })
|
||||
|
||||
@@ -132,9 +132,9 @@ function sessionPath(worktree: string, cwd: string) {
|
||||
}
|
||||
|
||||
const Summary = Schema.Struct({
|
||||
additions: Schema.Number,
|
||||
deletions: Schema.Number,
|
||||
files: Schema.Number,
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
files: NonNegativeInt,
|
||||
diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)),
|
||||
})
|
||||
|
||||
@@ -143,10 +143,10 @@ const Share = Schema.Struct({
|
||||
})
|
||||
|
||||
const Time = Schema.Struct({
|
||||
created: Schema.Number,
|
||||
updated: Schema.Number,
|
||||
compacting: optionalOmitUndefined(Schema.Number),
|
||||
archived: optionalOmitUndefined(Schema.Number),
|
||||
created: NonNegativeInt,
|
||||
updated: NonNegativeInt,
|
||||
compacting: optionalOmitUndefined(NonNegativeInt),
|
||||
archived: optionalOmitUndefined(NonNegativeInt),
|
||||
})
|
||||
|
||||
const Revert = Schema.Struct({
|
||||
@@ -215,7 +215,7 @@ export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema
|
||||
)
|
||||
export const SetArchivedInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
time: Schema.optional(Schema.Number),
|
||||
time: Schema.optional(NonNegativeInt),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export const SetPermissionInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
@@ -228,7 +228,7 @@ export const SetRevertInput = Schema.Struct({
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export const MessagesInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
limit: Schema.optional(Schema.Number),
|
||||
limit: Schema.optional(NonNegativeInt),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
const CreatedEventSchema = Schema.Struct({
|
||||
@@ -241,10 +241,10 @@ const UpdatedShare = Schema.Struct({
|
||||
})
|
||||
|
||||
const UpdatedTime = Schema.Struct({
|
||||
created: Schema.optional(Schema.NullOr(Schema.Number)),
|
||||
updated: Schema.optional(Schema.NullOr(Schema.Number)),
|
||||
compacting: Schema.optional(Schema.NullOr(Schema.Number)),
|
||||
archived: Schema.optional(Schema.NullOr(Schema.Number)),
|
||||
created: Schema.optional(Schema.NullOr(NonNegativeInt)),
|
||||
updated: Schema.optional(Schema.NullOr(NonNegativeInt)),
|
||||
compacting: Schema.optional(Schema.NullOr(NonNegativeInt)),
|
||||
archived: Schema.optional(Schema.NullOr(NonNegativeInt)),
|
||||
})
|
||||
|
||||
const UpdatedInfo = Schema.Struct({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { SessionID } from "./schema"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
@@ -13,9 +13,9 @@ export const Info = Schema.Union([
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("retry"),
|
||||
attempt: Schema.Number,
|
||||
attempt: NonNegativeInt,
|
||||
message: Schema.String,
|
||||
next: Schema.Number,
|
||||
next: NonNegativeInt,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("busy"),
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { Config } from "@/config/config"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
|
||||
export const Patch = Schema.Struct({
|
||||
@@ -22,8 +22,8 @@ export type Patch = typeof Patch.Type
|
||||
export const FileDiff = Schema.Struct({
|
||||
file: Schema.String,
|
||||
patch: Schema.String,
|
||||
additions: Schema.Number,
|
||||
deletions: Schema.Number,
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
|
||||
})
|
||||
.annotate({ identifier: "SnapshotFileDiff" })
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import z from "zod"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { Git } from "@/git"
|
||||
|
||||
const log = Log.create({ service: "storage" })
|
||||
@@ -41,8 +42,8 @@ const MessageFile = Schema.Struct({
|
||||
})
|
||||
|
||||
const DiffFile = Schema.Struct({
|
||||
additions: Schema.Number,
|
||||
deletions: Schema.Number,
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
})
|
||||
|
||||
const SummaryFile = Schema.Struct({
|
||||
|
||||
@@ -294,4 +294,20 @@ export function payloads() {
|
||||
.toArray()
|
||||
}
|
||||
|
||||
export function effectPayloads() {
|
||||
return registry
|
||||
.entries()
|
||||
.map(([type, def]) =>
|
||||
EffectSchema.Struct({
|
||||
type: EffectSchema.Literal("sync"),
|
||||
name: EffectSchema.Literal(type),
|
||||
id: EffectSchema.String,
|
||||
seq: EffectSchema.Finite,
|
||||
aggregateID: EffectSchema.Literal(def.aggregate),
|
||||
data: def.schema,
|
||||
}).annotate({ identifier: `SyncEvent.${type}` }),
|
||||
)
|
||||
.toArray()
|
||||
}
|
||||
|
||||
export * as SyncEvent from "."
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "@/util/schema"
|
||||
import os from "os"
|
||||
import { createWriteStream } from "node:fs"
|
||||
import * as Tool from "./tool"
|
||||
@@ -53,7 +54,7 @@ const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurs
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "The command to execute" }),
|
||||
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }),
|
||||
timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }),
|
||||
workdir: Schema.optional(Schema.String).annotate({
|
||||
description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
|
||||
}),
|
||||
|
||||
@@ -9,7 +9,7 @@ export const Parameters = Schema.Struct({
|
||||
description:
|
||||
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
|
||||
}),
|
||||
tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000))
|
||||
tokensNum: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000))
|
||||
.check(Schema.isLessThanOrEqualTo(50000))
|
||||
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000)))
|
||||
.annotate({
|
||||
|
||||
@@ -23,12 +23,12 @@ const operations = [
|
||||
export const Parameters = Schema.Struct({
|
||||
operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }),
|
||||
filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }),
|
||||
line: Schema.Number.check(Schema.isInt())
|
||||
.check(Schema.isGreaterThanOrEqualTo(1))
|
||||
.annotate({ description: "The line number (1-based, as shown in editors)" }),
|
||||
character: Schema.Number.check(Schema.isInt())
|
||||
.check(Schema.isGreaterThanOrEqualTo(1))
|
||||
.annotate({ description: "The character offset (1-based, as shown in editors)" }),
|
||||
line: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({
|
||||
description: "The line number (1-based, as shown in editors)",
|
||||
}),
|
||||
character: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({
|
||||
description: "The character offset (1-based, as shown in editors)",
|
||||
}),
|
||||
query: Schema.optional(Schema.String).annotate({
|
||||
description: "Search query for workspaceSymbol. Empty string requests all symbols.",
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Option, Schema, Scope } from "effect"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { createReadStream } from "fs"
|
||||
import * as path from "path"
|
||||
import { createInterface } from "readline"
|
||||
@@ -25,10 +26,10 @@ const SAMPLE_BYTES = 4096
|
||||
// unchanged; purely CLI-facing uses must now send numbers rather than strings.
|
||||
export const Parameters = Schema.Struct({
|
||||
filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }),
|
||||
offset: Schema.optional(Schema.Number).annotate({
|
||||
offset: Schema.optional(NonNegativeInt).annotate({
|
||||
description: "The line number to start reading from (1-indexed)",
|
||||
}),
|
||||
limit: Schema.optional(Schema.Number).annotate({
|
||||
limit: Schema.optional(NonNegativeInt).annotate({
|
||||
description: "The maximum number of lines to read (defaults to 2000)",
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -26,10 +26,17 @@ export function namedSchemaError<Tag extends string, Fields extends Schema.Struc
|
||||
})
|
||||
.meta({ ref: tag })
|
||||
|
||||
// Effect Schema for the wire shape — used by HttpApi OpenAPI generation.
|
||||
const effectSchema = Schema.Struct({
|
||||
name: Schema.Literal(tag),
|
||||
data: dataSchema,
|
||||
}).annotate({ identifier: tag })
|
||||
|
||||
type Data = Schema.Schema.Type<typeof dataSchema>
|
||||
|
||||
class NamedSchemaError extends Error {
|
||||
static readonly Schema = wire
|
||||
static readonly EffectSchema = effectSchema
|
||||
static readonly tag = tag
|
||||
public static isInstance(input: unknown): input is NamedSchemaError {
|
||||
return typeof input === "object" && input !== null && "name" in input && (input as { name: unknown }).name === tag
|
||||
|
||||
@@ -11,6 +11,8 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
|
||||
*/
|
||||
export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Optional public JSON field that can hold explicit `undefined` on the type
|
||||
* side but encodes it as an omitted key, matching legacy `JSON.stringify`.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { SessionEvent } from "./session-event"
|
||||
|
||||
export const ID = SessionEvent.ID
|
||||
@@ -105,7 +106,7 @@ export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Sessio
|
||||
}) {}
|
||||
|
||||
export class AssistantRetry extends Schema.Class<AssistantRetry>("Session.Entry.Assistant.Retry")({
|
||||
attempt: Schema.Number,
|
||||
attempt: NonNegativeInt,
|
||||
error: SessionEvent.RetryError,
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
@@ -132,14 +133,14 @@ export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant"
|
||||
type: Schema.Literal("assistant"),
|
||||
content: AssistantContent.pipe(Schema.Array),
|
||||
retries: AssistantRetry.pipe(Schema.Array, Schema.optional),
|
||||
cost: Schema.Number.pipe(Schema.optional),
|
||||
cost: Schema.Finite.pipe(Schema.optional),
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
reasoning: Schema.Number,
|
||||
input: NonNegativeInt,
|
||||
output: NonNegativeInt,
|
||||
reasoning: NonNegativeInt,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
read: NonNegativeInt,
|
||||
write: NonNegativeInt,
|
||||
}),
|
||||
}).pipe(Schema.optional),
|
||||
error: Schema.String.pipe(Schema.optional),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Identifier } from "@/id/id"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -25,8 +25,8 @@ export namespace SessionEvent {
|
||||
}
|
||||
|
||||
export class Source extends Schema.Class<Source>("Session.Event.Source")({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
@@ -55,7 +55,7 @@ export namespace SessionEvent {
|
||||
|
||||
export class RetryError extends Schema.Class<RetryError>("Session.Event.Retry.Error")({
|
||||
message: Schema.String,
|
||||
statusCode: Schema.Number.pipe(Schema.optional),
|
||||
statusCode: NonNegativeInt.pipe(Schema.optional),
|
||||
isRetryable: Schema.Boolean,
|
||||
responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
responseBody: Schema.String.pipe(Schema.optional),
|
||||
@@ -123,14 +123,14 @@ export namespace SessionEvent {
|
||||
...Base,
|
||||
type: Schema.Literal("step.ended"),
|
||||
reason: Schema.String,
|
||||
cost: Schema.Number,
|
||||
cost: Schema.Finite,
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
reasoning: Schema.Number,
|
||||
input: NonNegativeInt,
|
||||
output: NonNegativeInt,
|
||||
reasoning: NonNegativeInt,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
read: NonNegativeInt,
|
||||
write: NonNegativeInt,
|
||||
}),
|
||||
}),
|
||||
}) {
|
||||
@@ -395,7 +395,7 @@ export namespace SessionEvent {
|
||||
export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
|
||||
...Base,
|
||||
type: Schema.Literal("retried"),
|
||||
attempt: Schema.Number,
|
||||
attempt: NonNegativeInt,
|
||||
error: RetryError,
|
||||
}) {
|
||||
static create(input: BaseInput & { attempt: number; error: RetryError }) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ControlPaths } from "../../src/server/routes/instance/httpapi/control"
|
||||
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file"
|
||||
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/global"
|
||||
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
|
||||
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
|
||||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -57,16 +57,32 @@ function openApiParameters(spec: { paths: Record<string, Partial<Record<(typeof
|
||||
)
|
||||
}
|
||||
|
||||
function openApiRequestBodies(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }) {
|
||||
function openApiRequestBodies(spec: OpenApiSpec) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(spec.paths).flatMap(([path, item]) =>
|
||||
methods
|
||||
.filter((method) => item[method])
|
||||
.map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(item[method]?.requestBody)]),
|
||||
.map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(spec, item[method]?.requestBody)]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
type OpenApiSpec = {
|
||||
components?: {
|
||||
schemas?: Record<string, unknown>
|
||||
}
|
||||
paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>>
|
||||
}
|
||||
|
||||
type OpenApiSchema = {
|
||||
$ref?: string
|
||||
allOf?: unknown[]
|
||||
anyOf?: unknown[]
|
||||
oneOf?: unknown[]
|
||||
properties?: Record<string, unknown>
|
||||
type?: string | string[]
|
||||
}
|
||||
|
||||
type Operation = {
|
||||
parameters?: unknown[]
|
||||
responses?: unknown
|
||||
@@ -74,7 +90,7 @@ type Operation = {
|
||||
}
|
||||
|
||||
type RequestBody = {
|
||||
content?: Record<string, { schema?: { $ref?: string; type?: string } }>
|
||||
content?: Record<string, { schema?: OpenApiSchema }>
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
@@ -97,17 +113,27 @@ function parameterSchema(input: {
|
||||
return param.schema
|
||||
}
|
||||
|
||||
function requestBodyKey(body: unknown) {
|
||||
function requestBodyKey(spec: OpenApiSpec, body: unknown) {
|
||||
if (!body || typeof body !== "object" || !("content" in body)) return ""
|
||||
const requestBody = body as RequestBody
|
||||
return JSON.stringify({
|
||||
required: requestBody.required === true,
|
||||
content: Object.entries(requestBody.content ?? {})
|
||||
.map(([type, value]) => [type, value.schema?.$ref ?? value.schema?.type ?? "inline"])
|
||||
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)])
|
||||
.sort(),
|
||||
})
|
||||
}
|
||||
|
||||
function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) {
|
||||
if (!schema) return ""
|
||||
const resolved = (schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema) as
|
||||
| OpenApiSchema
|
||||
| undefined
|
||||
if (resolved?.properties) return "object"
|
||||
if (resolved?.anyOf ?? resolved?.oneOf ?? resolved?.allOf) return "object"
|
||||
return resolved?.type ?? schema.type ?? "inline"
|
||||
}
|
||||
|
||||
function responseContentTypes(input: {
|
||||
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
|
||||
path: string
|
||||
@@ -146,6 +172,14 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("HttpApi server", () => {
|
||||
test("keeps Effect HttpApi behind the feature flag", () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
|
||||
expect(Server.backend()).toEqual({ backend: "hono", reason: "stable" })
|
||||
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
expect(Server.backend()).toEqual({ backend: "effect-httpapi", reason: "env" })
|
||||
})
|
||||
|
||||
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
|
||||
const honoRoutes = openApiRouteKeys(await Server.openapi())
|
||||
const effectRoutes = openApiRouteKeys(effectOpenApi())
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { Session } from "@/session/session"
|
||||
import { Database } from "@/storage/db"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Context } from "effect"
|
||||
import path from "path"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { FilePaths } from "../../src/server/routes/instance/httpapi/file"
|
||||
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
@@ -4,8 +4,8 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/session"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { Session } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -19,7 +19,7 @@ const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
|
||||
function app(experimental: boolean) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||
return Server.Default().app
|
||||
return experimental ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
type TestApp = ReturnType<typeof app>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { McpPaths } from "../../src/server/routes/instance/httpapi/mcp"
|
||||
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -19,7 +19,7 @@ const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))
|
||||
|
||||
function app(experimental: boolean) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||
return Server.Default().app
|
||||
return experimental ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
type TestApp = ReturnType<typeof app>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const oauthInstructions = "Finish OAuth"
|
||||
|
||||
function app(experimental: boolean) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||
return Server.Default().app
|
||||
return experimental ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
|
||||
function requestAuthorize(input: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { PtyID } from "../../src/pty/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/pty"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
@@ -1,40 +1,209 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { TestLLMServer } from "../lib/llm-server"
|
||||
import path from "path"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const original = {
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
type Backend = "legacy" | "httpapi"
|
||||
type Sdk = ReturnType<typeof createOpencodeClient>
|
||||
type SdkResult = { response: Response; data?: unknown; error?: unknown }
|
||||
|
||||
function client(directory?: string) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
const handler = ExperimentalHttpApiServer.webHandler().handler
|
||||
function app(backend: Backend, input?: { password?: string; username?: string }) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi"
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
return backend === "httpapi" ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
|
||||
function client(
|
||||
backend: Backend,
|
||||
directory?: string,
|
||||
input?: { password?: string; username?: string; headers?: Record<string, string> },
|
||||
) {
|
||||
const serverApp = app(backend, input)
|
||||
const fetch = Object.assign(
|
||||
(request: RequestInfo | URL, init?: RequestInit) =>
|
||||
handler(new Request(request, init), ExperimentalHttpApiServer.context),
|
||||
async (request: RequestInfo | URL, init?: RequestInit) =>
|
||||
await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
) satisfies typeof globalThis.fetch
|
||||
return createOpencodeClient({
|
||||
baseUrl: "http://localhost",
|
||||
directory,
|
||||
headers: input?.headers,
|
||||
fetch,
|
||||
})
|
||||
}
|
||||
|
||||
function authorization(username: string, password: string) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
function providerConfig(url: string) {
|
||||
return {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
provider: {
|
||||
test: {
|
||||
name: "Test",
|
||||
id: "test",
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: {
|
||||
"test-model": {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: false,
|
||||
tool_call: true,
|
||||
release_date: "2025-01-01",
|
||||
limit: { context: 100000, output: 10000 },
|
||||
cost: { input: 0, output: 0 },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: url,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function expectStatus(result: Promise<{ response: Response }>, status: number) {
|
||||
expect((await result).response.status).toBe(status)
|
||||
}
|
||||
|
||||
async function capture(result: Promise<SdkResult>) {
|
||||
const response = await result
|
||||
return {
|
||||
status: response.response.status,
|
||||
data: response.data,
|
||||
error: response.error,
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
function array(value: unknown) {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function statuses(input: Record<string, Awaited<ReturnType<typeof capture>>>) {
|
||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value.status]))
|
||||
}
|
||||
|
||||
function firstPartText(value: unknown) {
|
||||
return record(array(record(value).parts)[0]).text
|
||||
}
|
||||
|
||||
function sessionTitles(value: unknown) {
|
||||
return array(value)
|
||||
.map((item) => record(item).title)
|
||||
.filter((title): title is string => typeof title === "string")
|
||||
.sort()
|
||||
}
|
||||
|
||||
async function runSession<A, E>(directory: string, effect: Effect.Effect<A, E, SessionNs.Service>) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
fn: () => Effect.runPromise(effect.pipe(Effect.provide(SessionNs.defaultLayer))),
|
||||
})
|
||||
}
|
||||
|
||||
async function seedMessage(directory: string, sessionID: string) {
|
||||
const id = SessionID.make(sessionID)
|
||||
return runSession(
|
||||
directory,
|
||||
SessionNs.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const message = yield* svc.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
sessionID: id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "test",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
tools: {},
|
||||
mode: "",
|
||||
} as unknown as MessageV2.Info)
|
||||
const part = yield* svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID: id,
|
||||
messageID: message.id,
|
||||
type: "text",
|
||||
text: "seeded message",
|
||||
})
|
||||
return { message, part }
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function compareBackends<T>(scenario: (backend: Backend) => Promise<T>) {
|
||||
const legacy = await scenario("legacy")
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
const httpapi = await scenario("httpapi")
|
||||
expect(httpapi).toEqual(legacy)
|
||||
}
|
||||
|
||||
async function withTmp<T>(backend: Backend, fn: (input: { sdk: Sdk; directory: string }) => Promise<T>) {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
config: { formatter: false, lsp: false },
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "hello.txt"), "hello")
|
||||
await Bun.write(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n")
|
||||
},
|
||||
})
|
||||
return fn({ sdk: client(backend, tmp.path), directory: tmp.path })
|
||||
}
|
||||
|
||||
async function withFakeLlm<T>(
|
||||
backend: Backend,
|
||||
fn: (input: { sdk: Sdk; directory: string; llm: TestLLMServer["Service"] }) => Promise<T>,
|
||||
) {
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLMServer
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir({ git: true, config: providerConfig(llm.url) })),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
return yield* Effect.promise(() => fn({ sdk: client(backend, tmp.path), directory: tmp.path, llm }))
|
||||
}).pipe(Effect.scoped, Effect.provide(TestLLMServer.layer)),
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("HttpApi SDK", () => {
|
||||
test("uses the generated SDK for global and control routes", async () => {
|
||||
const sdk = client()
|
||||
const sdk = client("httpapi")
|
||||
const health = await sdk.global.health()
|
||||
|
||||
expect(health.response.status).toBe(200)
|
||||
@@ -60,7 +229,7 @@ describe("HttpApi SDK", () => {
|
||||
config: { formatter: false, lsp: false },
|
||||
init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello"),
|
||||
})
|
||||
const sdk = client(tmp.path)
|
||||
const sdk = client("httpapi", tmp.path)
|
||||
|
||||
const file = await sdk.file.read({ path: "hello.txt" })
|
||||
expect(file.response.status).toBe(200)
|
||||
@@ -81,4 +250,381 @@ describe("HttpApi SDK", () => {
|
||||
expectStatus(sdk.find.files({ query: "hello", limit: 10 }), 200),
|
||||
])
|
||||
})
|
||||
|
||||
test("matches generated SDK global and control behavior across backends", async () => {
|
||||
await compareBackends(async (backend) => {
|
||||
const sdk = client(backend)
|
||||
const health = await capture(sdk.global.health())
|
||||
const log = await capture(sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" }))
|
||||
const invalidAuth = await capture(sdk.auth.set({ providerID: "test" }))
|
||||
|
||||
return {
|
||||
statuses: statuses({ health, log, invalidAuth }),
|
||||
health: record(health.data).healthy,
|
||||
log: log.data,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("matches generated SDK global event stream across backends", async () => {
|
||||
await compareBackends(async (backend) => {
|
||||
const events = await client(backend).global.event({ signal: AbortSignal.timeout(1_000) })
|
||||
try {
|
||||
const first = await events.stream.next()
|
||||
return {
|
||||
type: record(record(first.value).payload).type,
|
||||
}
|
||||
} finally {
|
||||
await events.stream.return(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("matches generated SDK instance event stream across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withTmp(backend, async ({ sdk }) => {
|
||||
const events = await sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) })
|
||||
try {
|
||||
const first = await events.stream.next()
|
||||
return {
|
||||
type: record(record(first.value).payload).type,
|
||||
}
|
||||
} finally {
|
||||
await events.stream.return(undefined)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK basic auth behavior across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withTmp(backend, async ({ directory }) => {
|
||||
const missing = await capture(
|
||||
client(backend, directory, { password: "secret" }).file.read({ path: "hello.txt" }),
|
||||
)
|
||||
const bad = await capture(
|
||||
client(backend, directory, {
|
||||
password: "secret",
|
||||
headers: { authorization: authorization("opencode", "wrong") },
|
||||
}).file.read({ path: "hello.txt" }),
|
||||
)
|
||||
const good = await capture(
|
||||
client(backend, directory, {
|
||||
password: "secret",
|
||||
headers: { authorization: authorization("opencode", "secret") },
|
||||
}).file.read({ path: "hello.txt" }),
|
||||
)
|
||||
|
||||
return {
|
||||
statuses: statuses({ missing, bad, good }),
|
||||
content: record(good.data).content,
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK instance read routes across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withTmp(backend, async ({ sdk, directory }) => {
|
||||
const project = await capture(sdk.project.current())
|
||||
const projects = await capture(sdk.project.list())
|
||||
const paths = await capture(sdk.path.get())
|
||||
const config = await capture(sdk.config.get())
|
||||
const providers = await capture(sdk.config.providers())
|
||||
const file = await capture(sdk.file.read({ path: "hello.txt" }))
|
||||
const files = await capture(sdk.file.list({ path: "." }))
|
||||
const fileStatus = await capture(sdk.file.status())
|
||||
const findFiles = await capture(sdk.find.files({ query: "hello", limit: 10 }))
|
||||
const findText = await capture(sdk.find.text({ pattern: "sdk-parity" }))
|
||||
const agents = await capture(sdk.app.agents())
|
||||
const skills = await capture(sdk.app.skills())
|
||||
const tools = await capture(sdk.tool.ids())
|
||||
const vcs = await capture(sdk.vcs.get())
|
||||
const formatter = await capture(sdk.formatter.status())
|
||||
const lsp = await capture(sdk.lsp.status())
|
||||
|
||||
return {
|
||||
statuses: statuses({
|
||||
project,
|
||||
projects,
|
||||
paths,
|
||||
config,
|
||||
providers,
|
||||
file,
|
||||
files,
|
||||
fileStatus,
|
||||
findFiles,
|
||||
findText,
|
||||
agents,
|
||||
skills,
|
||||
tools,
|
||||
vcs,
|
||||
formatter,
|
||||
lsp,
|
||||
}),
|
||||
project: {
|
||||
worktreeSelected: record(project.data).worktree === directory,
|
||||
},
|
||||
paths: {
|
||||
cwdSelected: record(paths.data).cwd === directory,
|
||||
},
|
||||
file: record(file.data).content,
|
||||
hasProject: array(projects.data).length > 0,
|
||||
foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
|
||||
foundText: JSON.stringify(findText.data ?? null).includes("sdk-parity"),
|
||||
listedFile: JSON.stringify(files.data).includes("hello.txt"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK session lifecycle routes across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withTmp(backend, async ({ sdk }) => {
|
||||
const parent = await capture(sdk.session.create({ title: "parent" }))
|
||||
const parentID = String(record(parent.data).id)
|
||||
const child = await capture(sdk.session.create({ title: "child", parentID }))
|
||||
const childID = String(record(child.data).id)
|
||||
const get = await capture(sdk.session.get({ sessionID: parentID }))
|
||||
const update = await capture(sdk.session.update({ sessionID: parentID, title: "renamed" }))
|
||||
const roots = await capture(sdk.session.list({ roots: true, limit: 10 }))
|
||||
const all = await capture(sdk.session.list({ roots: false, limit: 10 }))
|
||||
const children = await capture(sdk.session.children({ sessionID: parentID }))
|
||||
const todo = await capture(sdk.session.todo({ sessionID: parentID }))
|
||||
const status = await capture(sdk.session.status())
|
||||
const messages = await capture(sdk.session.messages({ sessionID: parentID }))
|
||||
const missingGet = await capture(sdk.session.get({ sessionID: "ses_missing" }))
|
||||
const missingMessages = await capture(sdk.session.messages({ sessionID: "ses_missing", limit: 2 }))
|
||||
const invalidCursor = await capture(sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" }))
|
||||
const deleted = await capture(sdk.session.delete({ sessionID: childID }))
|
||||
const getDeleted = await capture(sdk.session.get({ sessionID: childID }))
|
||||
|
||||
return {
|
||||
statuses: statuses({
|
||||
parent,
|
||||
child,
|
||||
get,
|
||||
update,
|
||||
roots,
|
||||
all,
|
||||
children,
|
||||
todo,
|
||||
status,
|
||||
messages,
|
||||
missingGet,
|
||||
missingMessages,
|
||||
invalidCursor,
|
||||
deleted,
|
||||
getDeleted,
|
||||
}),
|
||||
getTitle: record(get.data).title,
|
||||
updatedTitle: record(update.data).title,
|
||||
rootTitles: sessionTitles(roots.data),
|
||||
allTitles: sessionTitles(all.data),
|
||||
childCount: array(children.data).length,
|
||||
todoCount: array(todo.data).length,
|
||||
messageCount: array(messages.data).length,
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK session message and part routes across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withTmp(backend, async ({ sdk, directory }) => {
|
||||
const session = await capture(sdk.session.create({ title: "messages" }))
|
||||
const sessionID = String(record(session.data).id)
|
||||
const seeded = await seedMessage(directory, sessionID)
|
||||
const list = await capture(sdk.session.messages({ sessionID }))
|
||||
const page = await capture(sdk.session.messages({ sessionID, limit: 1 }))
|
||||
const message = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
||||
const partUpdate = await capture(
|
||||
sdk.part.update({
|
||||
sessionID,
|
||||
messageID: seeded.message.id,
|
||||
partID: seeded.part.id,
|
||||
part: {
|
||||
...seeded.part,
|
||||
text: "updated message",
|
||||
} as NonNullable<Parameters<Sdk["part"]["update"]>[0]["part"]>,
|
||||
}),
|
||||
)
|
||||
const updated = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
||||
const partDelete = await capture(
|
||||
sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }),
|
||||
)
|
||||
const withoutPart = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
||||
const deleteMessage = await capture(sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id }))
|
||||
const missingMessage = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
||||
|
||||
return {
|
||||
statuses: statuses({
|
||||
session,
|
||||
list,
|
||||
page,
|
||||
message,
|
||||
partUpdate,
|
||||
updated,
|
||||
partDelete,
|
||||
withoutPart,
|
||||
deleteMessage,
|
||||
missingMessage,
|
||||
}),
|
||||
listCount: array(list.data).length,
|
||||
pageCount: array(page.data).length,
|
||||
initialText: firstPartText(message.data),
|
||||
updatedText: firstPartText(updated.data),
|
||||
partCountAfterDelete: array(record(withoutPart.data).parts).length,
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK prompt no-reply routes across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withTmp(backend, async ({ sdk }) => {
|
||||
const session = await capture(sdk.session.create({ title: "prompt" }))
|
||||
const sessionID = String(record(session.data).id)
|
||||
const prompt = await capture(
|
||||
sdk.session.prompt({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
}),
|
||||
)
|
||||
const asyncPrompt = await capture(
|
||||
sdk.session.promptAsync({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "async hello" }],
|
||||
}),
|
||||
)
|
||||
const messages = await capture(sdk.session.messages({ sessionID }))
|
||||
|
||||
return {
|
||||
statuses: statuses({ session, prompt, asyncPrompt, messages }),
|
||||
promptRole: record(record(prompt.data).info).role,
|
||||
messageCount: array(messages.data).length,
|
||||
messageTexts: array(messages.data)
|
||||
.flatMap((item) => array(record(item).parts))
|
||||
.map((part) => record(part).text)
|
||||
.filter((text): text is string => typeof text === "string")
|
||||
.sort(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK prompt streaming through fake LLM across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withFakeLlm(backend, async ({ sdk, llm }) => {
|
||||
await Effect.runPromise(llm.text("fake world", { usage: { input: 11, output: 7 } }))
|
||||
const session = await capture(
|
||||
sdk.session.create({
|
||||
title: "llm prompt",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
}),
|
||||
)
|
||||
const sessionID = String(record(session.data).id)
|
||||
const prompt = await capture(
|
||||
sdk.session.prompt({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: { providerID: "test", modelID: "test-model" },
|
||||
parts: [{ type: "text", text: "hello llm" }],
|
||||
}),
|
||||
)
|
||||
const messages = await capture(sdk.session.messages({ sessionID }))
|
||||
const inputs = await Effect.runPromise(llm.inputs)
|
||||
|
||||
return {
|
||||
statuses: statuses({ session, prompt, messages }),
|
||||
calls: inputs.length,
|
||||
requestedModel: inputs[0]?.model,
|
||||
responseText: JSON.stringify(prompt.data).includes("fake world"),
|
||||
persistedText: JSON.stringify(messages.data).includes("fake world"),
|
||||
userText: JSON.stringify(messages.data).includes("hello llm"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK TUI validation and command routes across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withTmp(backend, async ({ sdk }) => {
|
||||
const session = await capture(sdk.session.create({ title: "tui" }))
|
||||
const sessionID = String(record(session.data).id)
|
||||
const appendPrompt = await capture(sdk.tui.appendPrompt({ text: "hello" }))
|
||||
const openHelp = await capture(sdk.tui.openHelp())
|
||||
const openSessions = await capture(sdk.tui.openSessions())
|
||||
const openThemes = await capture(sdk.tui.openThemes())
|
||||
const openModels = await capture(sdk.tui.openModels())
|
||||
const submitPrompt = await capture(sdk.tui.submitPrompt())
|
||||
const clearPrompt = await capture(sdk.tui.clearPrompt())
|
||||
const executeCommand = await capture(sdk.tui.executeCommand({ command: "session_new" }))
|
||||
const showToast = await capture(sdk.tui.showToast({ title: "SDK", message: "hello", variant: "info" }))
|
||||
const selectSession = await capture(sdk.tui.selectSession({ sessionID }))
|
||||
const missingSession = await capture(sdk.tui.selectSession({ sessionID: "ses_missing" }))
|
||||
const invalidSession = await capture(sdk.tui.selectSession({ sessionID: "invalid_session_id" }))
|
||||
|
||||
return {
|
||||
statuses: statuses({
|
||||
session,
|
||||
appendPrompt,
|
||||
openHelp,
|
||||
openSessions,
|
||||
openThemes,
|
||||
openModels,
|
||||
submitPrompt,
|
||||
clearPrompt,
|
||||
executeCommand,
|
||||
showToast,
|
||||
selectSession,
|
||||
missingSession,
|
||||
invalidSession,
|
||||
}),
|
||||
data: {
|
||||
appendPrompt: appendPrompt.data,
|
||||
openHelp: openHelp.data,
|
||||
openSessions: openSessions.data,
|
||||
openThemes: openThemes.data,
|
||||
openModels: openModels.data,
|
||||
submitPrompt: submitPrompt.data,
|
||||
clearPrompt: clearPrompt.data,
|
||||
executeCommand: executeCommand.data,
|
||||
showToast: showToast.data,
|
||||
selectSession: selectSession.data,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK project git initialization across backends", async () => {
|
||||
await compareBackends(async (backend) => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const sdk = client(backend, tmp.path)
|
||||
const before = await capture(sdk.project.current())
|
||||
const init = await capture(sdk.project.initGit())
|
||||
const after = await capture(sdk.project.current())
|
||||
|
||||
return {
|
||||
statuses: statuses({ before, init, after }),
|
||||
before: {
|
||||
vcs: record(before.data).vcs ?? null,
|
||||
worktree: record(before.data).worktree,
|
||||
},
|
||||
init: {
|
||||
vcs: record(init.data).vcs,
|
||||
worktreeSelected: record(init.data).worktree === tmp.path,
|
||||
},
|
||||
after: {
|
||||
vcs: record(after.data).vcs,
|
||||
worktreeSelected: record(after.data).worktree === tmp.path,
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PermissionID } from "../../src/permission/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/session"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SyncPaths } from "../../src/server/routes/instance/httpapi/sync"
|
||||
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
|
||||
import { Session } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
@@ -16,7 +16,7 @@ const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
function app(httpapi = true) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi
|
||||
return Server.Default().app
|
||||
return httpapi ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Context } from "hono"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/tui"
|
||||
import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui"
|
||||
import { callTui } from "../../src/server/routes/instance/tui"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
@@ -6,13 +6,14 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { registerAdaptor } from "../../src/control-plane/adaptors"
|
||||
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import { Session } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -54,7 +55,41 @@ function localAdaptor(directory: string): WorkspaceAdaptor {
|
||||
}
|
||||
}
|
||||
|
||||
function remoteAdaptor(directory: string, url: string): WorkspaceAdaptor {
|
||||
return {
|
||||
name: "Remote Test",
|
||||
description: "Create a remote test workspace",
|
||||
configure(info) {
|
||||
return {
|
||||
...info,
|
||||
name: "remote-test",
|
||||
directory,
|
||||
}
|
||||
},
|
||||
async create() {
|
||||
await mkdir(directory, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target() {
|
||||
return {
|
||||
type: "remote" as const,
|
||||
url,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function eventStreamResponse() {
|
||||
return new Response(new ReadableStream({ start() {} }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
|
||||
await Instance.disposeAll()
|
||||
@@ -125,4 +160,81 @@ describe("workspace HttpApi", () => {
|
||||
expect(listed.status).toBe(200)
|
||||
expect(await listed.json()).toEqual([])
|
||||
})
|
||||
|
||||
test("routes local workspace requests through the workspace target directory", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const workspaceDir = path.join(tmp.path, ".workspace-local")
|
||||
const workspace = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
registerAdaptor(Instance.project.id, "local-target", localAdaptor(workspaceDir))
|
||||
return Workspace.create({
|
||||
type: "local-target",
|
||||
branch: null,
|
||||
extra: null,
|
||||
projectID: Instance.project.id,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const url = new URL(`http://localhost${InstancePaths.path}`)
|
||||
url.searchParams.set("workspace", workspace.id)
|
||||
|
||||
try {
|
||||
const response = await request(url.toString(), tmp.path)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ directory: workspaceDir })
|
||||
} finally {
|
||||
await Workspace.remove(workspace.id)
|
||||
}
|
||||
})
|
||||
|
||||
test("proxies remote workspace HTTP requests", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const proxied: string[] = []
|
||||
const rawFetch = globalThis.fetch
|
||||
spyOn(globalThis, "fetch").mockImplementation(
|
||||
Object.assign(
|
||||
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
|
||||
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url)
|
||||
if (url.pathname === "/base/global/event") return eventStreamResponse()
|
||||
if (url.pathname === "/base/sync/history") return Response.json([])
|
||||
proxied.push(url.toString())
|
||||
return Response.json({ proxied: true, path: url.pathname, workspace: url.searchParams.get("workspace") })
|
||||
},
|
||||
{
|
||||
preconnect: rawFetch.preconnect?.bind(rawFetch),
|
||||
},
|
||||
) as typeof globalThis.fetch,
|
||||
)
|
||||
|
||||
const workspace = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
registerAdaptor(Instance.project.id, "remote-target", remoteAdaptor(path.join(tmp.path, ".remote"), "https://remote.test/base"))
|
||||
return Workspace.create({
|
||||
type: "remote-target",
|
||||
branch: null,
|
||||
extra: null,
|
||||
projectID: Instance.project.id,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const url = new URL(`http://localhost${InstancePaths.path}`)
|
||||
url.searchParams.set("workspace", workspace.id)
|
||||
|
||||
try {
|
||||
const response = await request(url.toString(), tmp.path)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ proxied: true, path: "/base/path", workspace: null })
|
||||
expect(proxied).toEqual(["https://remote.test/base/path"])
|
||||
} finally {
|
||||
await Workspace.remove(workspace.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,7 +43,9 @@ Output: Creates directory 'foo'"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Optional timeout in milliseconds",
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"type": "integer",
|
||||
},
|
||||
"workdir": {
|
||||
"description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.",
|
||||
@@ -71,7 +73,7 @@ exports[`tool parameters JSON Schema (wire shape) codesearch 1`] = `
|
||||
"description": "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
|
||||
"maximum": 50000,
|
||||
"minimum": 1000,
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
@@ -224,7 +226,6 @@ exports[`tool parameters JSON Schema (wire shape) lsp 1`] = `
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
exports[`tool parameters JSON Schema (wire shape) plan 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
@@ -304,11 +305,15 @@ exports[`tool parameters JSON Schema (wire shape) read 1`] = `
|
||||
},
|
||||
"limit": {
|
||||
"description": "The maximum number of lines to read (defaults to 2000)",
|
||||
"type": "number",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer",
|
||||
},
|
||||
"offset": {
|
||||
"description": "The line number to start reading from (1-indexed)",
|
||||
"type": "number",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
|
||||
Reference in New Issue
Block a user