Prepare Effect HttpApi backend parity (#24853)
This commit is contained in:
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.
|
- 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.
|
- 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
|
### 5. Make HttpApi Default For JSON Routes
|
||||||
|
|
||||||
After JSON parity and SDK generation are covered:
|
After JSON parity and SDK generation are covered:
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export const Info = Schema.Struct({
|
|||||||
mode: Schema.Literals(["subagent", "primary", "all"]),
|
mode: Schema.Literals(["subagent", "primary", "all"]),
|
||||||
native: Schema.optional(Schema.Boolean),
|
native: Schema.optional(Schema.Boolean),
|
||||||
hidden: Schema.optional(Schema.Boolean),
|
hidden: Schema.optional(Schema.Boolean),
|
||||||
topP: Schema.optional(Schema.Number),
|
topP: Schema.optional(Schema.Finite),
|
||||||
temperature: Schema.optional(Schema.Number),
|
temperature: Schema.optional(Schema.Finite),
|
||||||
color: Schema.optional(Schema.String),
|
color: Schema.optional(Schema.String),
|
||||||
permission: Permission.Ruleset,
|
permission: Permission.Ruleset,
|
||||||
model: Schema.optional(
|
model: Schema.optional(
|
||||||
@@ -44,7 +44,7 @@ export const Info = Schema.Struct({
|
|||||||
variant: Schema.optional(Schema.String),
|
variant: Schema.optional(Schema.String),
|
||||||
prompt: Schema.optional(Schema.String),
|
prompt: Schema.optional(Schema.String),
|
||||||
options: Schema.Record(Schema.String, Schema.Unknown),
|
options: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
steps: Schema.optional(Schema.Number),
|
steps: Schema.optional(Schema.Finite),
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "Agent" })
|
.annotate({ identifier: "Agent" })
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
|
import { NonNegativeInt } from "@/util/schema"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
|||||||
type: Schema.Literal("oauth"),
|
type: Schema.Literal("oauth"),
|
||||||
refresh: Schema.String,
|
refresh: Schema.String,
|
||||||
access: Schema.String,
|
access: Schema.String,
|
||||||
expires: Schema.Number,
|
expires: NonNegativeInt,
|
||||||
accountId: Schema.optional(Schema.String),
|
accountId: Schema.optional(Schema.String),
|
||||||
enterpriseUrl: Schema.optional(Schema.String),
|
enterpriseUrl: Schema.optional(Schema.String),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -34,4 +34,16 @@ export function payloads() {
|
|||||||
.toArray()
|
.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"
|
export * as BusEvent from "./bus-event"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
|
import { PositiveInt } from "@/util/schema"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
|
|
||||||
const DEFAULT_TOAST_DURATION = 5000
|
const DEFAULT_TOAST_DURATION = 5000
|
||||||
@@ -38,7 +39,7 @@ export const TuiEvent = {
|
|||||||
title: Schema.optional(Schema.String),
|
title: Schema.optional(Schema.String),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
variant: Schema.Literals(["info", "success", "warning", "error"]),
|
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",
|
description: "Duration in milliseconds",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ const AgentSchema = Schema.StructWithRest(
|
|||||||
variant: Schema.optional(Schema.String).annotate({
|
variant: Schema.optional(Schema.String).annotate({
|
||||||
description: "Default model variant for this agent (applies only when using the agent's configured model).",
|
description: "Default model variant for this agent (applies only when using the agent's configured model).",
|
||||||
}),
|
}),
|
||||||
temperature: Schema.optional(Schema.Number),
|
temperature: Schema.optional(Schema.Finite),
|
||||||
top_p: Schema.optional(Schema.Number),
|
top_p: Schema.optional(Schema.Finite),
|
||||||
prompt: Schema.optional(Schema.String),
|
prompt: Schema.optional(Schema.String),
|
||||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
|
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
|
||||||
description: "@deprecated Use 'permission' field instead",
|
description: "@deprecated Use 'permission' field instead",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
|
import { NonNegativeInt } from "@/util/schema"
|
||||||
|
|
||||||
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
|
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
|
||||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||||
activeOrgName: Schema.optional(Schema.String),
|
activeOrgName: Schema.optional(Schema.String),
|
||||||
switchableOrgCount: Schema.Number,
|
switchableOrgCount: NonNegativeInt,
|
||||||
}) {
|
}) {
|
||||||
static readonly zod = zod(this)
|
static readonly zod = zod(this)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { withStatics } from "@/util/schema"
|
import { PositiveInt, withStatics } from "@/util/schema"
|
||||||
|
|
||||||
export const Local = Schema.Struct({
|
export const Local = Schema.Struct({
|
||||||
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
|
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({
|
enabled: Schema.optional(Schema.Boolean).annotate({
|
||||||
description: "Enable or disable the MCP server on startup",
|
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.",
|
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({
|
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.",
|
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.",
|
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(
|
cost: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
input: Schema.Number,
|
input: Schema.Finite,
|
||||||
output: Schema.Number,
|
output: Schema.Finite,
|
||||||
cache_read: Schema.optional(Schema.Number),
|
cache_read: Schema.optional(Schema.Finite),
|
||||||
cache_write: Schema.optional(Schema.Number),
|
cache_write: Schema.optional(Schema.Finite),
|
||||||
context_over_200k: Schema.optional(
|
context_over_200k: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
input: Schema.Number,
|
input: Schema.Finite,
|
||||||
output: Schema.Number,
|
output: Schema.Finite,
|
||||||
cache_read: Schema.optional(Schema.Number),
|
cache_read: Schema.optional(Schema.Finite),
|
||||||
cache_write: Schema.optional(Schema.Number),
|
cache_write: Schema.optional(Schema.Finite),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
limit: Schema.optional(
|
limit: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
context: Schema.Number,
|
context: Schema.Finite,
|
||||||
input: Schema.optional(Schema.Number),
|
input: Schema.optional(Schema.Finite),
|
||||||
output: Schema.Number,
|
output: Schema.Finite,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
modalities: Schema.optional(
|
modalities: Schema.optional(
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ import * as Log from "@opencode-ai/core/util/log"
|
|||||||
import { Protected } from "./protected"
|
import { Protected } from "./protected"
|
||||||
import { Ripgrep } from "./ripgrep"
|
import { Ripgrep } from "./ripgrep"
|
||||||
import { zod } from "@/util/effect-zod"
|
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({
|
export const Info = Schema.Struct({
|
||||||
path: Schema.String,
|
path: Schema.String,
|
||||||
added: Schema.Int,
|
added: NonNegativeInt,
|
||||||
removed: Schema.Int,
|
removed: NonNegativeInt,
|
||||||
status: Schema.Literals(["added", "deleted", "modified"]),
|
status: Schema.Literals(["added", "deleted", "modified"]),
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "File" })
|
.annotate({ identifier: "File" })
|
||||||
@@ -39,10 +39,10 @@ export const Node = Schema.Struct({
|
|||||||
export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
|
export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
|
||||||
|
|
||||||
const Hunk = Schema.Struct({
|
const Hunk = Schema.Struct({
|
||||||
oldStart: Schema.Number,
|
oldStart: NonNegativeInt,
|
||||||
oldLines: Schema.Number,
|
oldLines: NonNegativeInt,
|
||||||
newStart: Schema.Number,
|
newStart: NonNegativeInt,
|
||||||
newLines: Schema.Number,
|
newLines: NonNegativeInt,
|
||||||
lines: Schema.Array(Schema.String),
|
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 { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
|
||||||
import { which } from "@/util/which"
|
import { which } from "@/util/which"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { withStatics } from "@/util/schema"
|
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||||
|
|
||||||
const log = Log.create({ service: "ripgrep" })
|
const log = Log.create({ service: "ripgrep" })
|
||||||
const VERSION = "15.1.0"
|
const VERSION = "15.1.0"
|
||||||
@@ -27,19 +27,19 @@ const PLATFORM = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
const TimeStats = Schema.Struct({
|
const TimeStats = Schema.Struct({
|
||||||
secs: Schema.Number,
|
secs: NonNegativeInt,
|
||||||
nanos: Schema.Number,
|
nanos: NonNegativeInt,
|
||||||
human: Schema.String,
|
human: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
const Stats = Schema.Struct({
|
const Stats = Schema.Struct({
|
||||||
elapsed: TimeStats,
|
elapsed: TimeStats,
|
||||||
searches: Schema.Number,
|
searches: NonNegativeInt,
|
||||||
searches_with_match: Schema.Number,
|
searches_with_match: NonNegativeInt,
|
||||||
bytes_searched: Schema.Number,
|
bytes_searched: NonNegativeInt,
|
||||||
bytes_printed: Schema.Number,
|
bytes_printed: NonNegativeInt,
|
||||||
matched_lines: Schema.Number,
|
matched_lines: NonNegativeInt,
|
||||||
matches: Schema.Number,
|
matches: NonNegativeInt,
|
||||||
})
|
})
|
||||||
|
|
||||||
const PathText = Schema.Struct({
|
const PathText = Schema.Struct({
|
||||||
@@ -58,15 +58,15 @@ export const SearchMatch = Schema.Struct({
|
|||||||
lines: Schema.Struct({
|
lines: Schema.Struct({
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
}),
|
}),
|
||||||
line_number: Schema.Number,
|
line_number: NonNegativeInt,
|
||||||
absolute_offset: Schema.Number,
|
absolute_offset: NonNegativeInt,
|
||||||
submatches: Schema.Array(
|
submatches: Schema.Array(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
match: Schema.Struct({
|
match: Schema.Struct({
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
}),
|
}),
|
||||||
start: Schema.Number,
|
start: NonNegativeInt,
|
||||||
end: Schema.Number,
|
end: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
@@ -80,7 +80,7 @@ const End = Schema.Struct({
|
|||||||
type: Schema.Literal("end"),
|
type: Schema.Literal("end"),
|
||||||
data: Schema.Struct({
|
data: Schema.Struct({
|
||||||
path: PathText,
|
path: PathText,
|
||||||
binary_offset: Schema.NullOr(Schema.Number),
|
binary_offset: Schema.NullOr(NonNegativeInt),
|
||||||
stats: Stats,
|
stats: Stats,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { spawn as lspspawn } from "./launch"
|
|||||||
import { Effect, Layer, Context, Schema } from "effect"
|
import { Effect, Layer, Context, Schema } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
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"
|
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||||
|
|
||||||
const log = Log.create({ service: "lsp" })
|
const log = Log.create({ service: "lsp" })
|
||||||
@@ -23,8 +23,8 @@ export const Event = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Position = Schema.Struct({
|
const Position = Schema.Struct({
|
||||||
line: Schema.Number,
|
line: NonNegativeInt,
|
||||||
character: Schema.Number,
|
character: NonNegativeInt,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Range = Schema.Struct({
|
export const Range = Schema.Struct({
|
||||||
@@ -37,7 +37,7 @@ export type Range = typeof Range.Type
|
|||||||
|
|
||||||
export const Symbol = Schema.Struct({
|
export const Symbol = Schema.Struct({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
kind: Schema.Number,
|
kind: NonNegativeInt,
|
||||||
location: Schema.Struct({
|
location: Schema.Struct({
|
||||||
uri: Schema.String,
|
uri: Schema.String,
|
||||||
range: Range,
|
range: Range,
|
||||||
@@ -50,7 +50,7 @@ export type Symbol = typeof Symbol.Type
|
|||||||
export const DocumentSymbol = Schema.Struct({
|
export const DocumentSymbol = Schema.Struct({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
detail: Schema.optional(Schema.String),
|
detail: Schema.optional(Schema.String),
|
||||||
kind: Schema.Number,
|
kind: NonNegativeInt,
|
||||||
range: Range,
|
range: Range,
|
||||||
selectionRange: Range,
|
selectionRange: Range,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { NodePath } from "@effect/platform-node"
|
|||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { withStatics } from "@/util/schema"
|
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||||
|
|
||||||
const log = Log.create({ service: "project" })
|
const log = Log.create({ service: "project" })
|
||||||
|
|
||||||
@@ -35,9 +35,9 @@ const ProjectCommands = Schema.Struct({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const ProjectTime = Schema.Struct({
|
const ProjectTime = Schema.Struct({
|
||||||
created: Schema.Number,
|
created: NonNegativeInt,
|
||||||
updated: Schema.Number,
|
updated: NonNegativeInt,
|
||||||
initialized: Schema.optional(Schema.Number),
|
initialized: Schema.optional(NonNegativeInt),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Info = Schema.Struct({
|
export const Info = Schema.Struct({
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { FileWatcher } from "@/file/watcher"
|
|||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { withStatics } from "@/util/schema"
|
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||||
|
|
||||||
const log = Log.create({ service: "vcs" })
|
const log = Log.create({ service: "vcs" })
|
||||||
|
|
||||||
@@ -125,8 +125,8 @@ export type Info = Schema.Schema.Type<typeof Info>
|
|||||||
export const FileDiff = Schema.Struct({
|
export const FileDiff = Schema.Struct({
|
||||||
file: Schema.String,
|
file: Schema.String,
|
||||||
patch: Schema.String,
|
patch: Schema.String,
|
||||||
additions: Schema.Number,
|
additions: NonNegativeInt,
|
||||||
deletions: Schema.Number,
|
deletions: NonNegativeInt,
|
||||||
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
|
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "VcsFileDiff" })
|
.annotate({ identifier: "VcsFileDiff" })
|
||||||
|
|||||||
@@ -58,13 +58,13 @@ export class Authorization extends Schema.Class<Authorization>("ProviderAuthAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AuthorizeInput = Schema.Struct({
|
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" }),
|
inputs: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Prompt inputs" }),
|
||||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
export type AuthorizeInput = Schema.Schema.Type<typeof AuthorizeInput>
|
export type AuthorizeInput = Schema.Schema.Type<typeof AuthorizeInput>
|
||||||
|
|
||||||
export const CallbackInput = Schema.Struct({
|
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" }),
|
code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }),
|
||||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
|
export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
|
||||||
|
|||||||
@@ -22,16 +22,16 @@ const filepath = path.join(
|
|||||||
const ttl = 5 * 60 * 1000
|
const ttl = 5 * 60 * 1000
|
||||||
|
|
||||||
const Cost = Schema.Struct({
|
const Cost = Schema.Struct({
|
||||||
input: Schema.Number,
|
input: Schema.Finite,
|
||||||
output: Schema.Number,
|
output: Schema.Finite,
|
||||||
cache_read: Schema.optional(Schema.Number),
|
cache_read: Schema.optional(Schema.Finite),
|
||||||
cache_write: Schema.optional(Schema.Number),
|
cache_write: Schema.optional(Schema.Finite),
|
||||||
context_over_200k: Schema.optional(
|
context_over_200k: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
input: Schema.Number,
|
input: Schema.Finite,
|
||||||
output: Schema.Number,
|
output: Schema.Finite,
|
||||||
cache_read: Schema.optional(Schema.Number),
|
cache_read: Schema.optional(Schema.Finite),
|
||||||
cache_write: Schema.optional(Schema.Number),
|
cache_write: Schema.optional(Schema.Finite),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -55,9 +55,9 @@ export const Model = Schema.Struct({
|
|||||||
),
|
),
|
||||||
cost: Schema.optional(Cost),
|
cost: Schema.optional(Cost),
|
||||||
limit: Schema.Struct({
|
limit: Schema.Struct({
|
||||||
context: Schema.Number,
|
context: Schema.Finite,
|
||||||
input: Schema.optional(Schema.Number),
|
input: Schema.optional(Schema.Finite),
|
||||||
output: Schema.Number,
|
output: Schema.Finite,
|
||||||
}),
|
}),
|
||||||
modalities: Schema.optional(
|
modalities: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
|
|||||||
@@ -848,27 +848,27 @@ const ProviderCapabilities = Schema.Struct({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const ProviderCacheCost = Schema.Struct({
|
const ProviderCacheCost = Schema.Struct({
|
||||||
read: Schema.Number,
|
read: Schema.Finite,
|
||||||
write: Schema.Number,
|
write: Schema.Finite,
|
||||||
})
|
})
|
||||||
|
|
||||||
const ProviderCost = Schema.Struct({
|
const ProviderCost = Schema.Struct({
|
||||||
input: Schema.Number,
|
input: Schema.Finite,
|
||||||
output: Schema.Number,
|
output: Schema.Finite,
|
||||||
cache: ProviderCacheCost,
|
cache: ProviderCacheCost,
|
||||||
experimentalOver200K: Schema.optional(
|
experimentalOver200K: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
input: Schema.Number,
|
input: Schema.Finite,
|
||||||
output: Schema.Number,
|
output: Schema.Finite,
|
||||||
cache: ProviderCacheCost,
|
cache: ProviderCacheCost,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
const ProviderLimit = Schema.Struct({
|
const ProviderLimit = Schema.Struct({
|
||||||
context: Schema.Number,
|
context: Schema.Finite,
|
||||||
input: Schema.optional(Schema.Number),
|
input: Schema.optional(Schema.Finite),
|
||||||
output: Schema.Number,
|
output: Schema.Finite,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Model = Schema.Struct({
|
export const Model = Schema.Struct({
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
|||||||
import { PtyID } from "./schema"
|
import { PtyID } from "./schema"
|
||||||
import { Effect, Layer, Context, Schema, Types } from "effect"
|
import { Effect, Layer, Context, Schema, Types } from "effect"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { withStatics } from "@/util/schema"
|
import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema"
|
||||||
|
|
||||||
const log = Log.create({ service: "pty" })
|
const log = Log.create({ service: "pty" })
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export const Info = Schema.Struct({
|
|||||||
args: Schema.Array(Schema.String),
|
args: Schema.Array(Schema.String),
|
||||||
cwd: Schema.String,
|
cwd: Schema.String,
|
||||||
status: Schema.Literals(["running", "exited"]),
|
status: Schema.Literals(["running", "exited"]),
|
||||||
pid: Schema.Number,
|
pid: PositiveInt,
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "Pty" })
|
.annotate({ identifier: "Pty" })
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
@@ -83,8 +83,8 @@ export const UpdateInput = Schema.Struct({
|
|||||||
title: Schema.optional(Schema.String),
|
title: Schema.optional(Schema.String),
|
||||||
size: Schema.optional(
|
size: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
rows: Schema.Number,
|
rows: PositiveInt,
|
||||||
cols: Schema.Number,
|
cols: PositiveInt,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
@@ -94,7 +94,7 @@ export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInpu
|
|||||||
export const Event = {
|
export const Event = {
|
||||||
Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })),
|
Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })),
|
||||||
Updated: BusEvent.define("pty.updated", 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 })),
|
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 { basicAuth } from "hono/basic-auth"
|
||||||
import { cors } from "hono/cors"
|
import { cors } from "hono/cors"
|
||||||
import { compress } from "hono/compress"
|
import { compress } from "hono/compress"
|
||||||
|
import * as ServerBackend from "./backend"
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
@@ -49,20 +50,20 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => {
|
|||||||
return basicAuth({ username, password })(c, next)
|
return basicAuth({ username, password })(c, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoggerMiddleware: MiddlewareHandler = async (c, next) => {
|
export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): MiddlewareHandler {
|
||||||
|
return async (c, next) => {
|
||||||
const skip = c.req.path === "/log"
|
const skip = c.req.path === "/log"
|
||||||
if (!skip) {
|
if (skip) return next()
|
||||||
log.info("request", {
|
const attributes = {
|
||||||
method: c.req.method,
|
method: c.req.method,
|
||||||
path: c.req.path,
|
path: c.req.path,
|
||||||
})
|
...backendAttributes,
|
||||||
}
|
}
|
||||||
const timer = log.time("request", {
|
log.info("request", attributes)
|
||||||
method: c.req.method,
|
const timer = log.time("request", attributes)
|
||||||
path: c.req.path,
|
|
||||||
})
|
|
||||||
await next()
|
await next()
|
||||||
if (!skip) timer.stop()
|
timer.stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
|
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function headers(req: Request, extra?: HeadersInit) {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
function protocols(req: Request) {
|
export function websocketProtocols(req: Request) {
|
||||||
const value = req.headers.get("sec-websocket-protocol")
|
const value = req.headers.get("sec-websocket-protocol")
|
||||||
if (!value) return []
|
if (!value) return []
|
||||||
return value
|
return value
|
||||||
@@ -42,7 +42,7 @@ function protocols(req: Request) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
function socket(url: string | URL) {
|
export function websocketTargetURL(url: string | URL) {
|
||||||
const next = new URL(url)
|
const next = new URL(url)
|
||||||
if (next.protocol === "http:") next.protocol = "ws:"
|
if (next.protocol === "http:") next.protocol = "ws:"
|
||||||
if (next.protocol === "https:") next.protocol = "wss:"
|
if (next.protocol === "https:") next.protocol = "wss:"
|
||||||
@@ -69,7 +69,7 @@ const app = (upgrade: UpgradeWebSocket) =>
|
|||||||
ws.close(1011, "missing proxy target")
|
ws.close(1011, "missing proxy target")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
remote = new WebSocket(url, protocols(c.req.raw))
|
remote = new WebSocket(url, websocketProtocols(c.req.raw))
|
||||||
remote.binaryType = "arraybuffer"
|
remote.binaryType = "arraybuffer"
|
||||||
remote.onopen = () => {
|
remote.onopen = () => {
|
||||||
for (const item of queue) remote?.send(item)
|
for (const item of queue) remote?.send(item)
|
||||||
@@ -150,7 +150,7 @@ export function websocket(
|
|||||||
proxy.pathname = "/__workspace_ws"
|
proxy.pathname = "/__workspace_ws"
|
||||||
proxy.search = ""
|
proxy.search = ""
|
||||||
const next = new Headers(req.headers)
|
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()) {
|
for (const [key, value] of new Headers(extra).entries()) {
|
||||||
next.set(key, value)
|
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 * as Stream from "effect/Stream"
|
||||||
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import * as Sse from "effect/unstable/encoding/Sse"
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
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." })),
|
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
|
||||||
)
|
)
|
||||||
|
|
||||||
function eventData(data: unknown) {
|
function eventData(data: unknown): Sse.Event {
|
||||||
return `data: ${JSON.stringify(data)}\n\n`
|
return {
|
||||||
|
_tag: "Event",
|
||||||
|
event: "message",
|
||||||
|
id: undefined,
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const eventRoute = HttpRouter.add(
|
export const eventRoute = HttpRouter.add(
|
||||||
@@ -47,6 +53,7 @@ export const eventRoute = HttpRouter.add(
|
|||||||
Stream.make({ type: "server.connected", properties: {} }).pipe(
|
Stream.make({ type: "server.connected", properties: {} }).pipe(
|
||||||
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
|
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
|
||||||
Stream.map(eventData),
|
Stream.map(eventData),
|
||||||
|
Stream.pipeThroughChannel(Sse.encode()),
|
||||||
Stream.encodeText,
|
Stream.encodeText,
|
||||||
Stream.ensuring(Effect.sync(() => log.info("event disconnected"))),
|
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 { Config } from "@/config/config"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import * as InstanceState from "@/effect/instance-state"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Effect } from "effect"
|
import { Authorization } from "../auth"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { InstanceContextMiddleware } from "../instance-context"
|
||||||
import { Authorization } from "./auth"
|
import { described } from "./metadata"
|
||||||
import { markInstanceForDisposal } from "./lifecycle"
|
|
||||||
|
|
||||||
const root = "/config"
|
const root = "/config"
|
||||||
|
|
||||||
@@ -13,7 +12,7 @@ export const ConfigApi = HttpApi.make("config")
|
|||||||
HttpApiGroup.make("config")
|
HttpApiGroup.make("config")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("get", root, {
|
HttpApiEndpoint.get("get", root, {
|
||||||
success: Config.Info,
|
success: described(Config.Info, "Get config info"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "config.get",
|
identifier: "config.get",
|
||||||
@@ -23,7 +22,8 @@ export const ConfigApi = HttpApi.make("config")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.patch("update", root, {
|
HttpApiEndpoint.patch("update", root, {
|
||||||
payload: Config.Info,
|
payload: Config.Info,
|
||||||
success: Config.Info,
|
success: described(Config.Info, "Successfully updated config"),
|
||||||
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "config.update",
|
identifier: "config.update",
|
||||||
@@ -32,7 +32,7 @@ export const ConfigApi = HttpApi.make("config")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("providers", `${root}/providers`, {
|
HttpApiEndpoint.get("providers", `${root}/providers`, {
|
||||||
success: Provider.ConfigProvidersResult,
|
success: described(Provider.ConfigProvidersResult, "List of providers"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "config.providers",
|
identifier: "config.providers",
|
||||||
@@ -47,6 +47,7 @@ export const ConfigApi = HttpApi.make("config")
|
|||||||
description: "Experimental HttpApi config routes.",
|
description: "Experimental HttpApi config routes.",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.middleware(InstanceContextMiddleware)
|
||||||
.middleware(Authorization),
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
@@ -56,30 +57,3 @@ export const ConfigApi = HttpApi.make("config")
|
|||||||
description: "Experimental HttpApi surface for selected instance routes.",
|
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 { Auth } from "@/auth"
|
||||||
import { ProviderID } from "@/provider/schema"
|
import { ProviderID } from "@/provider/schema"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import { Schema } from "effect"
|
||||||
import { Effect, Schema } from "effect"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const AuthParams = Schema.Struct({
|
const AuthParams = Schema.Struct({
|
||||||
providerID: ProviderID,
|
providerID: ProviderID,
|
||||||
@@ -13,7 +13,7 @@ const LogQuery = Schema.Struct({
|
|||||||
workspace: Schema.optional(Schema.String),
|
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" }),
|
service: Schema.String.annotate({ description: "Service name for the log entry" }),
|
||||||
level: Schema.Union([
|
level: Schema.Union([
|
||||||
Schema.Literal("debug"),
|
Schema.Literal("debug"),
|
||||||
@@ -25,7 +25,7 @@ const LogInput = Schema.Struct({
|
|||||||
extra: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({
|
extra: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({
|
||||||
description: "Additional metadata for the log entry",
|
description: "Additional metadata for the log entry",
|
||||||
}),
|
}),
|
||||||
}).annotate({ identifier: "AppLogInput" })
|
})
|
||||||
|
|
||||||
export const ControlPaths = {
|
export const ControlPaths = {
|
||||||
auth: "/auth/:providerID",
|
auth: "/auth/:providerID",
|
||||||
@@ -38,7 +38,8 @@ export const ControlApi = HttpApi.make("control").add(
|
|||||||
HttpApiEndpoint.put("authSet", ControlPaths.auth, {
|
HttpApiEndpoint.put("authSet", ControlPaths.auth, {
|
||||||
params: AuthParams,
|
params: AuthParams,
|
||||||
payload: Auth.Info,
|
payload: Auth.Info,
|
||||||
success: Schema.Boolean,
|
success: described(Schema.Boolean, "Successfully set authentication credentials"),
|
||||||
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "auth.set",
|
identifier: "auth.set",
|
||||||
@@ -48,7 +49,8 @@ export const ControlApi = HttpApi.make("control").add(
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.delete("authRemove", ControlPaths.auth, {
|
HttpApiEndpoint.delete("authRemove", ControlPaths.auth, {
|
||||||
params: AuthParams,
|
params: AuthParams,
|
||||||
success: Schema.Boolean,
|
success: described(Schema.Boolean, "Successfully removed authentication credentials"),
|
||||||
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "auth.remove",
|
identifier: "auth.remove",
|
||||||
@@ -59,7 +61,8 @@ export const ControlApi = HttpApi.make("control").add(
|
|||||||
HttpApiEndpoint.post("log", ControlPaths.log, {
|
HttpApiEndpoint.post("log", ControlPaths.log, {
|
||||||
query: LogQuery,
|
query: LogQuery,
|
||||||
payload: LogInput,
|
payload: LogInput,
|
||||||
success: Schema.Boolean,
|
success: described(Schema.Boolean, "Log entry written successfully"),
|
||||||
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "app.log",
|
identifier: "app.log",
|
||||||
@@ -70,30 +73,3 @@ export const ControlApi = HttpApi.make("control").add(
|
|||||||
)
|
)
|
||||||
.annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })),
|
.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 { 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 { MCP } from "@/mcp"
|
||||||
import { Project } from "@/project/project"
|
|
||||||
import { ProviderID, ModelID } from "@/provider/schema"
|
import { ProviderID, ModelID } from "@/provider/schema"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { ToolRegistry } from "@/tool/registry"
|
|
||||||
import * as EffectZod from "@/util/effect-zod"
|
|
||||||
import { Worktree } from "@/worktree"
|
import { Worktree } from "@/worktree"
|
||||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
import { NonNegativeInt } from "@/util/schema"
|
||||||
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
|
import { Schema, SchemaGetter } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "./auth"
|
import { Authorization } from "../auth"
|
||||||
|
import { InstanceContextMiddleware } from "../instance-context"
|
||||||
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const ConsoleStateResponse = Schema.Struct({
|
const ConsoleStateResponse = Schema.Struct({
|
||||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||||
activeOrgName: Schema.optionalKey(Schema.String),
|
activeOrgName: Schema.optionalKey(Schema.String),
|
||||||
switchableOrgCount: Schema.Number,
|
switchableOrgCount: NonNegativeInt,
|
||||||
}).annotate({ identifier: "ConsoleState" })
|
}).annotate({ identifier: "ConsoleState" })
|
||||||
|
|
||||||
const ConsoleOrgOption = Schema.Struct({
|
const ConsoleOrgOption = Schema.Struct({
|
||||||
@@ -28,25 +23,25 @@ const ConsoleOrgOption = Schema.Struct({
|
|||||||
orgID: Schema.String,
|
orgID: Schema.String,
|
||||||
orgName: Schema.String,
|
orgName: Schema.String,
|
||||||
active: Schema.Boolean,
|
active: Schema.Boolean,
|
||||||
}).annotate({ identifier: "ConsoleOrgOption" })
|
})
|
||||||
|
|
||||||
const ConsoleOrgList = Schema.Struct({
|
const ConsoleOrgList = Schema.Struct({
|
||||||
orgs: Schema.Array(ConsoleOrgOption),
|
orgs: Schema.Array(ConsoleOrgOption),
|
||||||
}).annotate({ identifier: "ConsoleOrgList" })
|
})
|
||||||
|
|
||||||
const ConsoleSwitchPayload = Schema.Struct({
|
export const ConsoleSwitchPayload = Schema.Struct({
|
||||||
accountID: AccountID,
|
accountID: AccountID,
|
||||||
orgID: OrgID,
|
orgID: OrgID,
|
||||||
}).annotate({ identifier: "ConsoleSwitchInput" })
|
})
|
||||||
|
|
||||||
const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" })
|
const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" })
|
||||||
const ToolListItem = Schema.Struct({
|
const ToolListItem = Schema.Struct({
|
||||||
id: Schema.String,
|
id: Schema.String,
|
||||||
description: Schema.String,
|
description: Schema.String,
|
||||||
parameters: Schema.Record(Schema.String, Schema.Any),
|
parameters: Schema.Unknown,
|
||||||
}).annotate({ identifier: "ToolListItem" })
|
}).annotate({ identifier: "ToolListItem" })
|
||||||
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
|
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
|
||||||
const ToolListQuery = Schema.Struct({
|
export const ToolListQuery = Schema.Struct({
|
||||||
provider: ProviderID,
|
provider: ProviderID,
|
||||||
model: ModelID,
|
model: ModelID,
|
||||||
})
|
})
|
||||||
@@ -57,8 +52,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
|||||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" })
|
const WorktreeList = Schema.Array(Schema.String)
|
||||||
const SessionListQuery = Schema.Struct({
|
export const SessionListQuery = Schema.Struct({
|
||||||
directory: Schema.optional(Schema.String),
|
directory: Schema.optional(Schema.String),
|
||||||
roots: Schema.optional(QueryBoolean),
|
roots: Schema.optional(QueryBoolean),
|
||||||
start: Schema.optional(Schema.NumberFromString),
|
start: Schema.optional(Schema.NumberFromString),
|
||||||
@@ -85,7 +80,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
HttpApiGroup.make("experimental")
|
HttpApiGroup.make("experimental")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
|
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
|
||||||
success: ConsoleStateResponse,
|
success: described(ConsoleStateResponse, "Active Console provider metadata"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "experimental.console.get",
|
identifier: "experimental.console.get",
|
||||||
@@ -94,7 +89,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
|
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
|
||||||
success: ConsoleOrgList,
|
success: described(ConsoleOrgList, "Switchable Console orgs"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "experimental.console.listOrgs",
|
identifier: "experimental.console.listOrgs",
|
||||||
@@ -104,7 +99,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
|
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
|
||||||
payload: ConsoleSwitchPayload,
|
payload: ConsoleSwitchPayload,
|
||||||
success: Schema.Boolean,
|
success: described(Schema.Boolean, "Switch success"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -115,7 +110,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("tool", ExperimentalPaths.tool, {
|
HttpApiEndpoint.get("tool", ExperimentalPaths.tool, {
|
||||||
query: ToolListQuery,
|
query: ToolListQuery,
|
||||||
success: ToolList,
|
success: described(ToolList, "Tools"),
|
||||||
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "tool.list",
|
identifier: "tool.list",
|
||||||
@@ -125,7 +121,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
|
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
|
||||||
success: ToolIDs,
|
success: described(ToolIDs, "Tool IDs"),
|
||||||
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "tool.ids",
|
identifier: "tool.ids",
|
||||||
@@ -135,7 +132,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
|
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
|
||||||
success: WorktreeList,
|
success: described(WorktreeList, "List of worktree directories"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "worktree.list",
|
identifier: "worktree.list",
|
||||||
@@ -145,7 +142,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
|
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
|
||||||
payload: Schema.optional(Worktree.CreateInput),
|
payload: Schema.optional(Worktree.CreateInput),
|
||||||
success: Worktree.Info,
|
success: described(Worktree.Info, "Worktree created"),
|
||||||
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "worktree.create",
|
identifier: "worktree.create",
|
||||||
@@ -155,7 +153,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
|
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
|
||||||
payload: Worktree.RemoveInput,
|
payload: Worktree.RemoveInput,
|
||||||
success: Schema.Boolean,
|
success: described(Schema.Boolean, "Worktree removed"),
|
||||||
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "worktree.remove",
|
identifier: "worktree.remove",
|
||||||
@@ -165,7 +164,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
|
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
|
||||||
payload: Worktree.ResetInput,
|
payload: Worktree.ResetInput,
|
||||||
success: Schema.Boolean,
|
success: described(Schema.Boolean, "Worktree reset"),
|
||||||
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "worktree.reset",
|
identifier: "worktree.reset",
|
||||||
@@ -175,7 +175,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("session", ExperimentalPaths.session, {
|
HttpApiEndpoint.get("session", ExperimentalPaths.session, {
|
||||||
query: SessionListQuery,
|
query: SessionListQuery,
|
||||||
success: Schema.Array(Session.GlobalInfo),
|
success: described(Schema.Array(Session.GlobalInfo), "List of sessions"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "experimental.session.list",
|
identifier: "experimental.session.list",
|
||||||
@@ -185,7 +185,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
|
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
|
||||||
success: Schema.Record(Schema.String, MCP.Resource),
|
success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "experimental.resource.list",
|
identifier: "experimental.resource.list",
|
||||||
@@ -200,6 +200,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
description: "Experimental HttpApi read-only routes.",
|
description: "Experimental HttpApi read-only routes.",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.middleware(InstanceContextMiddleware)
|
||||||
.middleware(Authorization),
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
@@ -209,143 +210,3 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
description: "Experimental HttpApi surface for selected instance routes.",
|
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 { File } from "@/file"
|
||||||
import { Ripgrep } from "@/file/ripgrep"
|
import { Ripgrep } from "@/file/ripgrep"
|
||||||
import * as InstanceState from "@/effect/instance-state"
|
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { Effect, Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "./auth"
|
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,
|
path: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
const FindTextQuery = Schema.Struct({
|
export const FindTextQuery = Schema.Struct({
|
||||||
pattern: Schema.String,
|
pattern: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
const FindFileQuery = Schema.Struct({
|
export const FindFileQuery = Schema.Struct({
|
||||||
query: Schema.String,
|
query: Schema.String,
|
||||||
dirs: Schema.optional(Schema.Literals(["true", "false"])),
|
dirs: Schema.optional(Schema.Literals(["true", "false"])),
|
||||||
type: Schema.optional(Schema.Literals(["file", "directory"])),
|
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,
|
query: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ export const FileApi = HttpApi.make("file")
|
|||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("findText", FilePaths.findText, {
|
HttpApiEndpoint.get("findText", FilePaths.findText, {
|
||||||
query: FindTextQuery,
|
query: FindTextQuery,
|
||||||
success: Schema.Array(Ripgrep.SearchMatch),
|
success: described(Schema.Array(Ripgrep.SearchMatch), "Matches"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "find.text",
|
identifier: "find.text",
|
||||||
@@ -52,7 +53,7 @@ export const FileApi = HttpApi.make("file")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("findFile", FilePaths.findFile, {
|
HttpApiEndpoint.get("findFile", FilePaths.findFile, {
|
||||||
query: FindFileQuery,
|
query: FindFileQuery,
|
||||||
success: Schema.Array(Schema.String),
|
success: described(Schema.Array(Schema.String), "File paths"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "find.files",
|
identifier: "find.files",
|
||||||
@@ -62,7 +63,7 @@ export const FileApi = HttpApi.make("file")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("findSymbol", FilePaths.findSymbol, {
|
HttpApiEndpoint.get("findSymbol", FilePaths.findSymbol, {
|
||||||
query: FindSymbolQuery,
|
query: FindSymbolQuery,
|
||||||
success: Schema.Array(LSP.Symbol),
|
success: described(Schema.Array(LSP.Symbol), "Symbols"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "find.symbols",
|
identifier: "find.symbols",
|
||||||
@@ -72,7 +73,7 @@ export const FileApi = HttpApi.make("file")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("list", FilePaths.list, {
|
HttpApiEndpoint.get("list", FilePaths.list, {
|
||||||
query: FileQuery,
|
query: FileQuery,
|
||||||
success: Schema.Array(File.Node),
|
success: described(Schema.Array(File.Node), "Files and directories"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "file.list",
|
identifier: "file.list",
|
||||||
@@ -82,7 +83,7 @@ export const FileApi = HttpApi.make("file")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("content", FilePaths.content, {
|
HttpApiEndpoint.get("content", FilePaths.content, {
|
||||||
query: FileQuery,
|
query: FileQuery,
|
||||||
success: File.Content,
|
success: described(File.Content, "File content"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "file.read",
|
identifier: "file.read",
|
||||||
@@ -91,7 +92,7 @@ export const FileApi = HttpApi.make("file")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("status", FilePaths.status, {
|
HttpApiEndpoint.get("status", FilePaths.status, {
|
||||||
success: Schema.Array(File.Info),
|
success: described(Schema.Array(File.Info), "File status"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "file.status",
|
identifier: "file.status",
|
||||||
@@ -106,6 +107,7 @@ export const FileApi = HttpApi.make("file")
|
|||||||
description: "Experimental HttpApi file routes.",
|
description: "Experimental HttpApi file routes.",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.middleware(InstanceContextMiddleware)
|
||||||
.middleware(Authorization),
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
@@ -115,51 +117,3 @@ export const FileApi = HttpApi.make("file")
|
|||||||
description: "Experimental HttpApi surface for selected instance routes.",
|
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 { Agent } from "@/agent/agent"
|
||||||
import { Command } from "@/command"
|
import { Command } from "@/command"
|
||||||
import { Format } from "@/format"
|
import { Format } from "@/format"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { Vcs } from "@/project/vcs"
|
import { Vcs } from "@/project/vcs"
|
||||||
import { Skill } from "@/skill"
|
import { Skill } from "@/skill"
|
||||||
import * as InstanceState from "@/effect/instance-state"
|
import { Schema } from "effect"
|
||||||
import { Effect, Schema } from "effect"
|
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { Authorization } from "../auth"
|
||||||
import { Authorization } from "./auth"
|
import { InstanceContextMiddleware } from "../instance-context"
|
||||||
import { markInstanceForDisposal } from "./lifecycle"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const PathInfo = Schema.Struct({
|
const PathInfo = Schema.Struct({
|
||||||
home: Schema.String,
|
home: Schema.String,
|
||||||
@@ -19,7 +18,7 @@ const PathInfo = Schema.Struct({
|
|||||||
directory: Schema.String,
|
directory: Schema.String,
|
||||||
}).annotate({ identifier: "Path" })
|
}).annotate({ identifier: "Path" })
|
||||||
|
|
||||||
const VcsDiffQuery = Schema.Struct({
|
export const VcsDiffQuery = Schema.Struct({
|
||||||
mode: Vcs.Mode,
|
mode: Vcs.Mode,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -40,7 +39,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
HttpApiGroup.make("instance")
|
HttpApiGroup.make("instance")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
|
HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
|
||||||
success: Schema.Boolean,
|
success: described(Schema.Boolean, "Instance disposed"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "instance.dispose",
|
identifier: "instance.dispose",
|
||||||
@@ -59,7 +58,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
|
HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
|
||||||
success: Vcs.Info,
|
success: described(Vcs.Info, "VCS info"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "vcs.get",
|
identifier: "vcs.get",
|
||||||
@@ -70,7 +69,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
|
HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
|
||||||
query: VcsDiffQuery,
|
query: VcsDiffQuery,
|
||||||
success: Schema.Array(Vcs.FileDiff),
|
success: described(Schema.Array(Vcs.FileDiff), "VCS diff"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "vcs.diff",
|
identifier: "vcs.diff",
|
||||||
@@ -79,7 +78,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("command", InstancePaths.command, {
|
HttpApiEndpoint.get("command", InstancePaths.command, {
|
||||||
success: Schema.Array(Command.Info),
|
success: described(Schema.Array(Command.Info), "List of commands"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "command.list",
|
identifier: "command.list",
|
||||||
@@ -88,7 +87,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("agent", InstancePaths.agent, {
|
HttpApiEndpoint.get("agent", InstancePaths.agent, {
|
||||||
success: Schema.Array(Agent.Info),
|
success: described(Schema.Array(Agent.Info), "List of agents"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "app.agents",
|
identifier: "app.agents",
|
||||||
@@ -97,7 +96,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("skill", InstancePaths.skill, {
|
HttpApiEndpoint.get("skill", InstancePaths.skill, {
|
||||||
success: Schema.Array(Skill.Info),
|
success: described(Schema.Array(Skill.Info), "List of skills"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "app.skills",
|
identifier: "app.skills",
|
||||||
@@ -106,7 +105,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
|
HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
|
||||||
success: Schema.Array(LSP.Status),
|
success: described(Schema.Array(LSP.Status), "LSP server status"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "lsp.status",
|
identifier: "lsp.status",
|
||||||
@@ -115,7 +114,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
|
HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
|
||||||
success: Schema.Array(Format.Status),
|
success: described(Schema.Array(Format.Status), "Formatter status"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "formatter.status",
|
identifier: "formatter.status",
|
||||||
@@ -130,6 +129,7 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
description: "Experimental HttpApi instance read routes.",
|
description: "Experimental HttpApi instance read routes.",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.middleware(InstanceContextMiddleware)
|
||||||
.middleware(Authorization),
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
@@ -139,70 +139,3 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
description: "Experimental HttpApi surface for selected instance routes.",
|
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 { MCP } from "@/mcp"
|
||||||
import { ConfigMCP } from "@/config/mcp"
|
import { ConfigMCP } from "@/config/mcp"
|
||||||
import { Effect, Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "./auth"
|
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,
|
name: Schema.String,
|
||||||
config: ConfigMCP.Info,
|
config: ConfigMCP.Info,
|
||||||
}).annotate({ identifier: "McpAddInput" })
|
})
|
||||||
|
|
||||||
const StatusMap = Schema.Record(Schema.String, MCP.Status)
|
export const StatusMap = Schema.Record(Schema.String, MCP.Status)
|
||||||
const AuthStartResponse = Schema.Struct({
|
export const AuthStartResponse = Schema.Struct({
|
||||||
authorizationUrl: Schema.String,
|
authorizationUrl: Schema.String,
|
||||||
oauthState: Schema.String,
|
})
|
||||||
}).annotate({ identifier: "McpAuthStartResponse" })
|
export const AuthCallbackPayload = Schema.Struct({
|
||||||
const AuthCallbackPayload = Schema.Struct({
|
|
||||||
code: Schema.String,
|
code: Schema.String,
|
||||||
}).annotate({ identifier: "McpAuthCallbackInput" })
|
})
|
||||||
const AuthRemoveResponse = Schema.Struct({
|
export const AuthRemoveResponse = Schema.Struct({
|
||||||
success: Schema.Literal(true),
|
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 },
|
{ error: Schema.String },
|
||||||
{ httpApiStatus: 400 },
|
{ httpApiStatus: 400 },
|
||||||
) {}
|
) {}
|
||||||
@@ -39,7 +40,7 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
HttpApiGroup.make("mcp")
|
HttpApiGroup.make("mcp")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("status", McpPaths.status, {
|
HttpApiEndpoint.get("status", McpPaths.status, {
|
||||||
success: Schema.Record(Schema.String, MCP.Status),
|
success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "mcp.status",
|
identifier: "mcp.status",
|
||||||
@@ -49,7 +50,7 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("add", McpPaths.status, {
|
HttpApiEndpoint.post("add", McpPaths.status, {
|
||||||
payload: AddPayload,
|
payload: AddPayload,
|
||||||
success: StatusMap,
|
success: described(StatusMap, "MCP server added successfully"),
|
||||||
error: HttpApiError.BadRequest,
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -60,8 +61,8 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("authStart", McpPaths.auth, {
|
HttpApiEndpoint.post("authStart", McpPaths.auth, {
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
success: AuthStartResponse,
|
success: described(AuthStartResponse, "OAuth flow started"),
|
||||||
error: UnsupportedOAuthError,
|
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "mcp.auth.start",
|
identifier: "mcp.auth.start",
|
||||||
@@ -72,7 +73,8 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
|
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
payload: AuthCallbackPayload,
|
payload: AuthCallbackPayload,
|
||||||
success: MCP.Status,
|
success: described(MCP.Status, "OAuth authentication completed"),
|
||||||
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "mcp.auth.callback",
|
identifier: "mcp.auth.callback",
|
||||||
@@ -83,8 +85,8 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
|
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
success: MCP.Status,
|
success: described(MCP.Status, "OAuth authentication completed"),
|
||||||
error: UnsupportedOAuthError,
|
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "mcp.auth.authenticate",
|
identifier: "mcp.auth.authenticate",
|
||||||
@@ -94,7 +96,8 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
|
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
success: AuthRemoveResponse,
|
success: described(AuthRemoveResponse, "OAuth credentials removed"),
|
||||||
|
error: HttpApiError.NotFound,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "mcp.auth.remove",
|
identifier: "mcp.auth.remove",
|
||||||
@@ -104,7 +107,7 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("connect", McpPaths.connect, {
|
HttpApiEndpoint.post("connect", McpPaths.connect, {
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
success: Schema.Boolean,
|
success: described(Schema.Boolean, "MCP server connected successfully"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "mcp.connect",
|
identifier: "mcp.connect",
|
||||||
@@ -113,7 +116,7 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
|
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
success: Schema.Boolean,
|
success: described(Schema.Boolean, "MCP server disconnected successfully"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "mcp.disconnect",
|
identifier: "mcp.disconnect",
|
||||||
@@ -127,6 +130,7 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
description: "Experimental HttpApi MCP routes.",
|
description: "Experimental HttpApi MCP routes.",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.middleware(InstanceContextMiddleware)
|
||||||
.middleware(Authorization),
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
@@ -136,66 +140,3 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
description: "Experimental HttpApi surface for selected instance routes.",
|
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 { Permission } from "@/permission"
|
||||||
import { PermissionID } from "@/permission/schema"
|
import { PermissionID } from "@/permission/schema"
|
||||||
import { Effect, Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "./auth"
|
import { Authorization } from "../auth"
|
||||||
|
import { InstanceContextMiddleware } from "../instance-context"
|
||||||
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const root = "/permission"
|
const root = "/permission"
|
||||||
|
const ReplyPayload = Schema.Struct({
|
||||||
|
reply: Permission.Reply,
|
||||||
|
message: Schema.optional(Schema.String),
|
||||||
|
})
|
||||||
|
|
||||||
export const PermissionApi = HttpApi.make("permission")
|
export const PermissionApi = HttpApi.make("permission")
|
||||||
.add(
|
.add(
|
||||||
HttpApiGroup.make("permission")
|
HttpApiGroup.make("permission")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("list", root, {
|
HttpApiEndpoint.get("list", root, {
|
||||||
success: Schema.Array(Permission.Request),
|
success: described(Schema.Array(Permission.Request), "List of pending permissions"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "permission.list",
|
identifier: "permission.list",
|
||||||
@@ -21,8 +27,9 @@ export const PermissionApi = HttpApi.make("permission")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||||
params: { requestID: PermissionID },
|
params: { requestID: PermissionID },
|
||||||
payload: Permission.ReplyBody,
|
payload: ReplyPayload,
|
||||||
success: Schema.Boolean,
|
success: described(Schema.Boolean, "Permission processed successfully"),
|
||||||
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "permission.reply",
|
identifier: "permission.reply",
|
||||||
@@ -37,6 +44,7 @@ export const PermissionApi = HttpApi.make("permission")
|
|||||||
description: "Experimental HttpApi permission routes.",
|
description: "Experimental HttpApi permission routes.",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.middleware(InstanceContextMiddleware)
|
||||||
.middleware(Authorization),
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
@@ -46,27 +54,3 @@ export const PermissionApi = HttpApi.make("permission")
|
|||||||
description: "Experimental HttpApi surface for selected instance routes.",
|
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 { Project } from "@/project/project"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
|
||||||
import { ProjectID } from "@/project/schema"
|
import { ProjectID } from "@/project/schema"
|
||||||
import { Effect, Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "./auth"
|
import { Authorization } from "../auth"
|
||||||
import { markInstanceForReload } from "./lifecycle"
|
import { InstanceContextMiddleware } from "../instance-context"
|
||||||
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const root = "/project"
|
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")
|
export const ProjectApi = HttpApi.make("project")
|
||||||
.add(
|
.add(
|
||||||
HttpApiGroup.make("project")
|
HttpApiGroup.make("project")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("list", root, {
|
HttpApiEndpoint.get("list", root, {
|
||||||
success: Schema.Array(Project.Info),
|
success: described(Schema.Array(Project.Info), "List of projects"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "project.list",
|
identifier: "project.list",
|
||||||
@@ -24,7 +27,7 @@ export const ProjectApi = HttpApi.make("project")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.get("current", `${root}/current`, {
|
HttpApiEndpoint.get("current", `${root}/current`, {
|
||||||
success: Project.Info,
|
success: described(Project.Info, "Current project information"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "project.current",
|
identifier: "project.current",
|
||||||
@@ -33,7 +36,7 @@ export const ProjectApi = HttpApi.make("project")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
HttpApiEndpoint.post("initGit", `${root}/git/init`, {
|
HttpApiEndpoint.post("initGit", `${root}/git/init`, {
|
||||||
success: Project.Info,
|
success: described(Project.Info, "Project information after git initialization"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "project.initGit",
|
identifier: "project.initGit",
|
||||||
@@ -43,8 +46,9 @@ export const ProjectApi = HttpApi.make("project")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
|
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
|
||||||
params: { projectID: ProjectID },
|
params: { projectID: ProjectID },
|
||||||
payload: Project.UpdatePayload,
|
payload: UpdatePayload,
|
||||||
success: Project.Info,
|
success: described(Project.Info, "Updated project information"),
|
||||||
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "project.update",
|
identifier: "project.update",
|
||||||
@@ -59,6 +63,7 @@ export const ProjectApi = HttpApi.make("project")
|
|||||||
description: "Experimental HttpApi project routes.",
|
description: "Experimental HttpApi project routes.",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.middleware(InstanceContextMiddleware)
|
||||||
.middleware(Authorization),
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
@@ -68,40 +73,3 @@ export const ProjectApi = HttpApi.make("project")
|
|||||||
description: "Experimental HttpApi surface for selected instance routes.",
|
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 { Question } from "@/question"
|
||||||
import { QuestionID } from "@/question/schema"
|
import { QuestionID } from "@/question/schema"
|
||||||
import { Effect, Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "./auth"
|
import { Authorization } from "../auth"
|
||||||
|
import { InstanceContextMiddleware } from "../instance-context"
|
||||||
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const root = "/question"
|
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")
|
export const QuestionApi = HttpApi.make("question")
|
||||||
.add(
|
.add(
|
||||||
HttpApiGroup.make("question")
|
HttpApiGroup.make("question")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("list", root, {
|
HttpApiEndpoint.get("list", root, {
|
||||||
success: Schema.Array(Question.Request),
|
success: described(Schema.Array(Question.Request), "List of pending questions"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "question.list",
|
identifier: "question.list",
|
||||||
@@ -21,8 +28,9 @@ export const QuestionApi = HttpApi.make("question")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||||
params: { requestID: QuestionID },
|
params: { requestID: QuestionID },
|
||||||
payload: Question.Reply,
|
payload: ReplyPayload,
|
||||||
success: Schema.Boolean,
|
success: described(Schema.Boolean, "Question answered successfully"),
|
||||||
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "question.reply",
|
identifier: "question.reply",
|
||||||
@@ -32,7 +40,8 @@ export const QuestionApi = HttpApi.make("question")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
|
HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
|
||||||
params: { requestID: QuestionID },
|
params: { requestID: QuestionID },
|
||||||
success: Schema.Boolean,
|
success: described(Schema.Boolean, "Question rejected successfully"),
|
||||||
|
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "question.reject",
|
identifier: "question.reject",
|
||||||
@@ -47,6 +56,7 @@ export const QuestionApi = HttpApi.make("question")
|
|||||||
description: "Question routes.",
|
description: "Question routes.",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.middleware(InstanceContextMiddleware)
|
||||||
.middleware(Authorization),
|
.middleware(Authorization),
|
||||||
)
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
@@ -56,31 +66,3 @@ export const QuestionApi = HttpApi.make("question")
|
|||||||
description: "Effect HttpApi surface for instance routes.",
|
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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
+17
-410
@@ -6,7 +6,6 @@ import { Command } from "@/command"
|
|||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { PermissionID } from "@/permission/schema"
|
import { PermissionID } from "@/permission/schema"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
|
||||||
import { SessionShare } from "@/share/session"
|
import { SessionShare } from "@/share/session"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { SessionCompaction } from "@/session/compaction"
|
import { SessionCompaction } from "@/session/compaction"
|
||||||
@@ -18,422 +17,27 @@ import { SessionStatus } from "@/session/status"
|
|||||||
import { SessionSummary } from "@/session/summary"
|
import { SessionSummary } from "@/session/summary"
|
||||||
import { Todo } from "@/session/todo"
|
import { Todo } from "@/session/todo"
|
||||||
import { MessageID, PartID, SessionID } from "@/session/schema"
|
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 * as Log from "@opencode-ai/core/util/log"
|
||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
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 * as Stream from "effect/Stream"
|
||||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||||
import {
|
import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
|
||||||
HttpApi,
|
import { InstanceHttpApi } from "../api"
|
||||||
HttpApiBuilder,
|
import { CommandPayload, DiffQuery, ForkPayload, InitPayload, ListQuery, MessagesQuery, PermissionResponsePayload, PromptPayload, RevertPayload, ShellPayload, SummarizePayload, UpdatePayload } from "../groups/session"
|
||||||
HttpApiEndpoint,
|
|
||||||
HttpApiError,
|
|
||||||
HttpApiGroup,
|
|
||||||
HttpApiSchema,
|
|
||||||
OpenApi,
|
|
||||||
} from "effect/unstable/httpapi"
|
|
||||||
import { Authorization } from "./auth"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
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 = {
|
const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
|
||||||
list: root,
|
self.pipe(
|
||||||
status: `${root}/status`,
|
Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))),
|
||||||
get: `${root}/:sessionID`,
|
Effect.catchDefect((error) =>
|
||||||
children: `${root}/:sessionID/children`,
|
NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error),
|
||||||
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.",
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (handlers) =>
|
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
const statusSvc = yield* SessionStatus.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 } }) {
|
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 } }) {
|
const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||||
@@ -484,6 +88,7 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand
|
|||||||
params: { sessionID: SessionID }
|
params: { sessionID: SessionID }
|
||||||
query: typeof MessagesQuery.Type
|
query: typeof MessagesQuery.Type
|
||||||
}) {
|
}) {
|
||||||
|
return yield* mapNotFound(Effect.gen(function* () {
|
||||||
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
|
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
|
||||||
if (ctx.query.before) {
|
if (ctx.query.before) {
|
||||||
const before = ctx.query.before
|
const before = ctx.query.before
|
||||||
@@ -497,6 +102,7 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand
|
|||||||
return yield* session.messages({ sessionID: ctx.params.sessionID })
|
return yield* session.messages({ sessionID: ctx.params.sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
yield* session.get(ctx.params.sessionID)
|
||||||
const page = MessageV2.page({
|
const page = MessageV2.page({
|
||||||
sessionID: ctx.params.sessionID,
|
sessionID: ctx.params.sessionID,
|
||||||
limit: ctx.query.limit,
|
limit: ctx.query.limit,
|
||||||
@@ -515,13 +121,14 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand
|
|||||||
"X-Next-Cursor": page.cursor,
|
"X-Next-Cursor": page.cursor,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
|
const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
|
||||||
params: { sessionID: SessionID; messageID: MessageID }
|
params: { sessionID: SessionID; messageID: MessageID }
|
||||||
}) {
|
}) {
|
||||||
return yield* Effect.sync(() =>
|
return yield* mapNotFound(
|
||||||
MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }),
|
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"
|
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
|
||||||
|
|
||||||
const disposeAfterResponse = new WeakMap<object, InstanceContext>()
|
const disposeAfterResponse = new WeakMap<object, InstanceContext>()
|
||||||
const reloadAfterResponse = new WeakMap<object, InstanceContext & { next: Parameters<typeof Instance.reload>[0] }>()
|
|
||||||
|
|
||||||
export const markInstanceForDisposal = (ctx: InstanceContext) =>
|
export const markInstanceForDisposal = (ctx: InstanceContext) =>
|
||||||
HttpEffect.appendPreResponseHandler((request, response) =>
|
HttpEffect.appendPreResponseHandler((request, response) =>
|
||||||
@@ -14,27 +13,17 @@ export const markInstanceForDisposal = (ctx: InstanceContext) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
|
export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
|
||||||
HttpEffect.appendPreResponseHandler((request, response) =>
|
HttpEffect.appendPreResponseHandler((_request, response) =>
|
||||||
Effect.sync(() => {
|
Effect.as(Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))), response),
|
||||||
reloadAfterResponse.set(request.source, { ...ctx, next })
|
|
||||||
return response
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
|
export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* effect
|
const response = yield* effect
|
||||||
const request = yield* HttpServerRequest.HttpServerRequest
|
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)
|
const ctx = disposeAfterResponse.get(request.source)
|
||||||
if (!ctx) return response
|
if (!ctx) return response
|
||||||
disposeAfterResponse.delete(request.source)
|
disposeAfterResponse.delete(request.source)
|
||||||
yield* Effect.promise(() => Instance.restore(ctx, () => Instance.dispose()))
|
yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose())))
|
||||||
return response
|
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 { OpenApi } from "effect/unstable/httpapi"
|
||||||
import { ConfigApi } from "./config"
|
import { OpenCodeHttpApi } from "./api"
|
||||||
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"
|
|
||||||
|
|
||||||
type OpenApiParameter = {
|
type OpenApiParameter = {
|
||||||
name: string
|
name: string
|
||||||
@@ -26,11 +10,12 @@ type OpenApiParameter = {
|
|||||||
|
|
||||||
type OpenApiOperation = {
|
type OpenApiOperation = {
|
||||||
parameters?: OpenApiParameter[]
|
parameters?: OpenApiParameter[]
|
||||||
responses?: Record<string, unknown>
|
responses?: Record<string, OpenApiResponse>
|
||||||
requestBody?: {
|
requestBody?: {
|
||||||
required?: boolean
|
required?: boolean
|
||||||
content?: Record<string, { schema?: OpenApiSchema }>
|
content?: Record<string, { schema?: OpenApiSchema }>
|
||||||
}
|
}
|
||||||
|
security?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
|
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 = {
|
type OpenApiSpec = {
|
||||||
components?: {
|
components?: {
|
||||||
schemas?: Record<string, OpenApiSchema>
|
schemas?: Record<string, OpenApiSchema>
|
||||||
|
securitySchemes?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
paths?: Record<string, OpenApiPathItem>
|
paths?: Record<string, OpenApiPathItem>
|
||||||
}
|
}
|
||||||
@@ -47,16 +33,25 @@ type OpenApiSchema = {
|
|||||||
additionalProperties?: OpenApiSchema | boolean
|
additionalProperties?: OpenApiSchema | boolean
|
||||||
allOf?: OpenApiSchema[]
|
allOf?: OpenApiSchema[]
|
||||||
anyOf?: OpenApiSchema[]
|
anyOf?: OpenApiSchema[]
|
||||||
enum?: string[]
|
description?: string
|
||||||
|
enum?: Array<string | boolean>
|
||||||
items?: OpenApiSchema
|
items?: OpenApiSchema
|
||||||
maximum?: number
|
maximum?: number
|
||||||
minimum?: number
|
minimum?: number
|
||||||
oneOf?: OpenApiSchema[]
|
oneOf?: OpenApiSchema[]
|
||||||
prefixItems?: OpenApiSchema[]
|
prefixItems?: OpenApiSchema[]
|
||||||
properties?: Record<string, OpenApiSchema>
|
properties?: Record<string, OpenApiSchema>
|
||||||
|
required?: string[]
|
||||||
type?: 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 = [
|
const InstanceQueryParameters = [
|
||||||
{
|
{
|
||||||
name: "directory",
|
name: "directory",
|
||||||
@@ -72,8 +67,9 @@ const InstanceQueryParameters = [
|
|||||||
},
|
},
|
||||||
] satisfies OpenApiParameter[]
|
] satisfies OpenApiParameter[]
|
||||||
|
|
||||||
const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"])
|
// Query schemas describe decoded Effect values, but the generated SDK needs the
|
||||||
const FiniteNumberValues = new Set(["Infinity", "-Infinity", "NaN"])
|
// 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 QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
|
||||||
const QueryBooleanParameters = new Set(["roots", "archived"])
|
const QueryBooleanParameters = new Set(["roots", "archived"])
|
||||||
const QueryParameterSchemas = {
|
const QueryParameterSchemas = {
|
||||||
@@ -81,60 +77,80 @@ const QueryParameterSchemas = {
|
|||||||
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||||
} satisfies Record<string, OpenApiSchema>
|
} 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>) {
|
function matchLegacyOpenApi(input: Record<string, unknown>) {
|
||||||
const spec = input as OpenApiSpec
|
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 ?? {})) {
|
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
||||||
const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
|
const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
|
||||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||||
const operation = item[method]
|
const operation = item[method]
|
||||||
if (!operation) continue
|
if (!operation) continue
|
||||||
if (operation.requestBody) {
|
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
|
delete operation.requestBody.required
|
||||||
for (const media of Object.values(operation.requestBody.content ?? {})) {
|
const body = operation.requestBody.content?.["application/json"]
|
||||||
const ref = media.schema?.$ref?.replace("#/components/schemas/", "")
|
if (body?.schema) body.schema = stripOptionalNull(structuredClone(body.schema))
|
||||||
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)
|
|
||||||
}
|
|
||||||
if (path === "/experimental/workspace" && method === "post") {
|
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?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] }
|
||||||
if (properties?.extra) properties.extra = { anyOf: [properties.extra, { 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
|
for (const response of Object.values(operation.responses ?? {})) {
|
||||||
const anyOf = schema?.anyOf
|
for (const content of Object.values(response.content ?? {})) {
|
||||||
if (anyOf?.length === 4) {
|
if (content.schema) content.schema = stripOptionalNull(structuredClone(content.schema))
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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") {
|
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"] = {
|
operation.responses!["200"] = {
|
||||||
description: "Event stream",
|
description: "Event stream",
|
||||||
content: {
|
content: {
|
||||||
"text/event-stream": {
|
"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
|
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)
|
const options = flattenOptions(schema.anyOf ?? schema.oneOf)
|
||||||
if (options) {
|
if (options) {
|
||||||
const withoutNull = options.filter((item) => item.type !== "null")
|
const withoutNull = options.filter((item) => item.type !== "null")
|
||||||
const finite = withoutNull.find((item) => item.type === "number")
|
if (withoutNull.length === 1) return stripOptionalNull(withoutNull[0])
|
||||||
if (finite && withoutNull.every(isFiniteNumberOption)) return { type: "number" }
|
if (schema.anyOf) schema.anyOf = withoutNull.map(stripOptionalNull)
|
||||||
if (withoutNull.length === 1) return normalizeRequestSchema(withoutNull[0])
|
if (schema.oneOf) schema.oneOf = withoutNull.map(stripOptionalNull)
|
||||||
if (schema.anyOf) schema.anyOf = withoutNull.map(normalizeRequestSchema)
|
|
||||||
if (schema.oneOf) schema.oneOf = withoutNull.map(normalizeRequestSchema)
|
|
||||||
}
|
}
|
||||||
if (schema.allOf) {
|
if (schema.allOf) {
|
||||||
if (schema.type) delete schema.allOf
|
const allOf = schema.allOf.map(stripOptionalNull)
|
||||||
else schema.allOf = schema.allOf.map(normalizeRequestSchema)
|
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.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) {
|
if (schema.properties) {
|
||||||
for (const [key, value] of Object.entries(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") {
|
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||||
schema.additionalProperties = normalizeRequestSchema(schema.additionalProperties)
|
schema.additionalProperties = stripOptionalNull(schema.additionalProperties)
|
||||||
}
|
}
|
||||||
return schema
|
return schema
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined {
|
function isEmptyObjectUnion(schema: OpenApiSchema) {
|
||||||
return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item])
|
const options = schema.anyOf ?? schema.oneOf
|
||||||
|
return options?.length === 2 && options.some(isBareObjectSchema) && options.some(isBareArraySchema)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFiniteNumberOption(schema: OpenApiSchema) {
|
function isBareObjectSchema(schema: OpenApiSchema) {
|
||||||
if (schema.type === "number") return true
|
return schema.type === "object" && !schema.properties && !schema.additionalProperties
|
||||||
return schema.type === "string" && schema.enum?.every((value) => FiniteNumberValues.has(value)) === true
|
}
|
||||||
|
|
||||||
|
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) {
|
function normalizeParameter(param: OpenApiParameter, route: string) {
|
||||||
@@ -205,28 +483,10 @@ function normalizeParameter(param: OpenApiParameter, route: string) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
param.schema = normalizeRequestSchema(param.schema)
|
param.schema = stripOptionalNull(param.schema)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PublicApi = HttpApi.make("opencode")
|
export const PublicApi = OpenCodeHttpApi
|
||||||
.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)
|
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
title: "opencode",
|
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 { 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 { Account } from "@/account/account"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Command } from "@/command"
|
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 * as Observability from "@opencode-ai/core/effect/observability"
|
||||||
import { File } from "@/file"
|
import { File } from "@/file"
|
||||||
import { Ripgrep } from "@/file/ripgrep"
|
import { Ripgrep } from "@/file/ripgrep"
|
||||||
@@ -16,8 +14,6 @@ import { Format } from "@/format"
|
|||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { MCP } from "@/mcp"
|
import { MCP } from "@/mcp"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project/project"
|
||||||
import { ProviderAuth } from "@/provider/auth"
|
import { ProviderAuth } from "@/provider/auth"
|
||||||
@@ -32,129 +28,101 @@ import { Todo } from "@/session/todo"
|
|||||||
import { Skill } from "@/skill"
|
import { Skill } from "@/skill"
|
||||||
import { ToolRegistry } from "@/tool/registry"
|
import { ToolRegistry } from "@/tool/registry"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
|
||||||
import { Vcs } from "@/project/vcs"
|
import { Vcs } from "@/project/vcs"
|
||||||
import { Worktree } from "@/worktree"
|
import { Worktree } from "@/worktree"
|
||||||
|
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||||
import { authorizationLayer } from "./auth"
|
import { authorizationLayer } from "./auth"
|
||||||
import { ConfigApi, configHandlers } from "./config"
|
|
||||||
import { ControlApi, controlHandlers } from "./control"
|
|
||||||
import { eventRoute } from "./event"
|
import { eventRoute } from "./event"
|
||||||
import { FileApi, fileHandlers } from "./file"
|
import { configHandlers } from "./handlers/config"
|
||||||
import { ExperimentalApi, experimentalHandlers } from "./experimental"
|
import { controlHandlers } from "./handlers/control"
|
||||||
import { GlobalApi, globalHandlers } from "./global"
|
import { experimentalHandlers } from "./handlers/experimental"
|
||||||
import { InstanceApi, instanceHandlers } from "./instance"
|
import { fileHandlers } from "./handlers/file"
|
||||||
import { McpApi, mcpHandlers } from "./mcp"
|
import { globalHandlers } from "./handlers/global"
|
||||||
import { PermissionApi, permissionHandlers } from "./permission"
|
import { instanceHandlers } from "./handlers/instance"
|
||||||
import { ProjectApi, projectHandlers } from "./project"
|
import { mcpHandlers } from "./handlers/mcp"
|
||||||
import { PtyApi, ptyConnectRoute, ptyHandlers } from "./pty"
|
import { permissionHandlers } from "./handlers/permission"
|
||||||
import { ProviderApi, providerHandlers } from "./provider"
|
import { projectHandlers } from "./handlers/project"
|
||||||
import { QuestionApi, questionHandlers } from "./question"
|
import { providerHandlers } from "./handlers/provider"
|
||||||
import { SessionApi, sessionHandlers } from "./session"
|
import { ptyConnectRoute, ptyHandlers } from "./handlers/pty"
|
||||||
import { SyncApi, syncHandlers } from "./sync"
|
import { questionHandlers } from "./handlers/question"
|
||||||
import { TuiApi, tuiHandlers } from "./tui"
|
import { sessionHandlers } from "./handlers/session"
|
||||||
import { WorkspaceApi, workspaceHandlers } from "./workspace"
|
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 { disposeMiddleware } from "./lifecycle"
|
||||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||||
|
import * as ServerBackend from "@/server/backend"
|
||||||
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),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const context = Context.empty() as Context.Context<unknown>
|
export const context = Context.empty() as Context.Context<unknown>
|
||||||
|
|
||||||
function decode(input: string) {
|
const runtime = HttpRouter.middleware()(
|
||||||
try {
|
Effect.succeed((effect) =>
|
||||||
return decodeURIComponent(input)
|
|
||||||
} catch {
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const instance = HttpRouter.middleware()(
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return (effect) =>
|
const selected = ServerBackend.select()
|
||||||
Effect.gen(function* () {
|
yield* Effect.annotateCurrentSpan(ServerBackend.attributes(ServerBackend.force(selected, "effect-httpapi")))
|
||||||
const query = yield* HttpServerRequest.schemaSearchParams(Query)
|
return yield* effect
|
||||||
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))
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
).layer
|
).layer
|
||||||
|
|
||||||
const controlRoutes = HttpApiBuilder.layer(ControlApi).pipe(Layer.provide(controlHandlers))
|
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers]))
|
||||||
const globalRoutes = HttpApiBuilder.layer(GlobalApi).pipe(Layer.provide(globalHandlers))
|
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||||
const instanceApiRoutes = Layer.mergeAll(
|
Layer.provide([
|
||||||
HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)),
|
configHandlers,
|
||||||
HttpApiBuilder.layer(ExperimentalApi).pipe(Layer.provide(experimentalHandlers)),
|
experimentalHandlers,
|
||||||
HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)),
|
fileHandlers,
|
||||||
HttpApiBuilder.layer(InstanceApi).pipe(Layer.provide(instanceHandlers)),
|
instanceHandlers,
|
||||||
HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)),
|
mcpHandlers,
|
||||||
HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)),
|
projectHandlers,
|
||||||
HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers)),
|
ptyHandlers,
|
||||||
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
|
questionHandlers,
|
||||||
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
|
permissionHandlers,
|
||||||
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
|
providerHandlers,
|
||||||
HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
|
sessionHandlers,
|
||||||
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
|
syncHandlers,
|
||||||
HttpApiBuilder.layer(TuiApi).pipe(Layer.provide(tuiHandlers)),
|
tuiHandlers,
|
||||||
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
|
workspaceHandlers,
|
||||||
|
]),
|
||||||
)
|
)
|
||||||
|
|
||||||
const instanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute, instanceApiRoutes).pipe(
|
const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
|
||||||
Layer.provide(authorizationLayer),
|
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
|
||||||
Layer.provide(instance),
|
Layer.provide([authorizationLayer, instanceContextLayer]),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const routes = Layer.mergeAll(controlRoutes, globalRoutes, instanceRoutes)
|
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
|
||||||
.pipe(
|
Layer.provide([
|
||||||
Layer.provide(Account.defaultLayer),
|
runtime,
|
||||||
Layer.provide(Agent.defaultLayer),
|
Account.defaultLayer,
|
||||||
Layer.provide(Auth.defaultLayer),
|
Agent.defaultLayer,
|
||||||
Layer.provide(Command.defaultLayer),
|
Auth.defaultLayer,
|
||||||
Layer.provide(Config.defaultLayer),
|
Command.defaultLayer,
|
||||||
Layer.provide(File.defaultLayer),
|
Config.defaultLayer,
|
||||||
Layer.provide(Format.defaultLayer),
|
File.defaultLayer,
|
||||||
Layer.provide(LSP.defaultLayer),
|
Format.defaultLayer,
|
||||||
Layer.provide(Installation.defaultLayer),
|
LSP.defaultLayer,
|
||||||
Layer.provide(MCP.defaultLayer),
|
Installation.defaultLayer,
|
||||||
Layer.provide(Permission.defaultLayer),
|
MCP.defaultLayer,
|
||||||
Layer.provide(Project.defaultLayer),
|
Permission.defaultLayer,
|
||||||
Layer.provide(ProviderAuth.defaultLayer),
|
Project.defaultLayer,
|
||||||
Layer.provide(Provider.defaultLayer),
|
ProviderAuth.defaultLayer,
|
||||||
Layer.provide(Pty.defaultLayer),
|
Provider.defaultLayer,
|
||||||
Layer.provide(Question.defaultLayer),
|
Pty.defaultLayer,
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Question.defaultLayer,
|
||||||
Layer.provide(Session.defaultLayer),
|
Ripgrep.defaultLayer,
|
||||||
)
|
Session.defaultLayer,
|
||||||
.pipe(
|
SessionRunState.defaultLayer,
|
||||||
Layer.provide(SessionRunState.defaultLayer),
|
SessionStatus.defaultLayer,
|
||||||
Layer.provide(SessionStatus.defaultLayer),
|
SessionSummary.defaultLayer,
|
||||||
Layer.provide(SessionSummary.defaultLayer),
|
Skill.defaultLayer,
|
||||||
Layer.provide(Skill.defaultLayer),
|
Todo.defaultLayer,
|
||||||
Layer.provide(Todo.defaultLayer),
|
ToolRegistry.defaultLayer,
|
||||||
Layer.provide(ToolRegistry.defaultLayer),
|
Vcs.defaultLayer,
|
||||||
Layer.provide(Vcs.defaultLayer),
|
Worktree.defaultLayer,
|
||||||
Layer.provide(Worktree.defaultLayer),
|
Bus.layer,
|
||||||
Layer.provide(Bus.layer),
|
HttpServer.layerServices,
|
||||||
Layer.provide(HttpServer.layerServices),
|
]),
|
||||||
Layer.provideMerge(Observability.layer),
|
Layer.provideMerge(Observability.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 { InstanceMiddleware } from "./routes/instance/middleware"
|
||||||
import { WorkspaceRoutes } from "./routes/control/workspace"
|
import { WorkspaceRoutes } from "./routes/control/workspace"
|
||||||
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
|
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
|
// @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
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
@@ -37,13 +38,38 @@ type ServerApp = {
|
|||||||
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
|
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultHono = lazy(() => createHono({}))
|
const DefaultHono = lazy(() => withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })))
|
||||||
const DefaultHttpApi = lazy(() => createHttpApi())
|
const DefaultHttpApi = lazy(() => createDefaultHttpApi())
|
||||||
export const Default = () => (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI ? DefaultHttpApi() : DefaultHono())
|
|
||||||
|
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[] }) {
|
function create(opts: { cors?: string[] }) {
|
||||||
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return createHttpApi()
|
const selected = select()
|
||||||
return createHono(opts)
|
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() {
|
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()
|
const app = new Hono()
|
||||||
.onError(ErrorMiddleware)
|
.onError(ErrorMiddleware)
|
||||||
.use(AuthMiddleware)
|
.use(AuthMiddleware)
|
||||||
.use(LoggerMiddleware)
|
.use(LoggerMiddleware(backendAttributes))
|
||||||
.use(CompressionMiddleware)
|
.use(CompressionMiddleware)
|
||||||
.use(CorsMiddleware(opts))
|
.use(CorsMiddleware(opts))
|
||||||
.route("/global", GlobalRoutes())
|
.route("/global", GlobalRoutes())
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const RULES: Array<Rule> = [
|
|||||||
{ method: "GET", path: "/session", action: "local" },
|
{ method: "GET", path: "/session", action: "local" },
|
||||||
]
|
]
|
||||||
|
|
||||||
function local(method: string, path: string) {
|
export function isLocalWorkspaceRoute(method: string, path: string) {
|
||||||
for (const rule of RULES) {
|
for (const rule of RULES) {
|
||||||
if (rule.method && rule.method !== method) continue
|
if (rule.method && rule.method !== method) continue
|
||||||
const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSessionID(url: URL) {
|
export function getWorkspaceRouteSessionID(url: URL) {
|
||||||
if (url.pathname === "/session/status") return null
|
if (url.pathname === "/session/status") return null
|
||||||
|
|
||||||
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
|
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
|
||||||
@@ -39,8 +39,17 @@ function getSessionID(url: URL) {
|
|||||||
return SessionID.make(id)
|
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) {
|
async function getSessionWorkspace(url: URL) {
|
||||||
const id = getSessionID(url)
|
const id = getWorkspaceRouteSessionID(url)
|
||||||
if (!id) return null
|
if (!id) return null
|
||||||
|
|
||||||
const session = await AppRuntime.runPromise(
|
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
|
// No instance provided because we are serving cached data; there
|
||||||
// is no instance to work with
|
// is no instance to work with
|
||||||
return next()
|
return next()
|
||||||
@@ -96,11 +105,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxyURL = new URL(target.url)
|
const proxyURL = workspaceProxyURL(target.url, url)
|
||||||
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${url.pathname}`
|
|
||||||
proxyURL.search = url.search
|
|
||||||
proxyURL.hash = url.hash
|
|
||||||
proxyURL.searchParams.delete("workspace")
|
|
||||||
|
|
||||||
log.info("workspace proxy forwarding", {
|
log.info("workspace proxy forwarding", {
|
||||||
workspaceID,
|
workspaceID,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}
|
|||||||
export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String })
|
export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String })
|
||||||
export const StructuredOutputError = namedSchemaError("StructuredOutputError", {
|
export const StructuredOutputError = namedSchemaError("StructuredOutputError", {
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
retries: Schema.Number,
|
retries: NonNegativeInt,
|
||||||
})
|
})
|
||||||
export const AuthError = namedSchemaError("ProviderAuthError", {
|
export const AuthError = namedSchemaError("ProviderAuthError", {
|
||||||
providerID: Schema.String,
|
providerID: Schema.String,
|
||||||
@@ -50,7 +50,7 @@ export const AuthError = namedSchemaError("ProviderAuthError", {
|
|||||||
})
|
})
|
||||||
export const APIError = namedSchemaError("APIError", {
|
export const APIError = namedSchemaError("APIError", {
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
statusCode: Schema.optional(Schema.Number),
|
statusCode: Schema.optional(NonNegativeInt),
|
||||||
isRetryable: Schema.Boolean,
|
isRetryable: Schema.Boolean,
|
||||||
responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||||
responseBody: Schema.optional(Schema.String),
|
responseBody: Schema.optional(Schema.String),
|
||||||
@@ -116,8 +116,8 @@ export const TextPart = Schema.Struct({
|
|||||||
ignored: Schema.optional(Schema.Boolean),
|
ignored: Schema.optional(Schema.Boolean),
|
||||||
time: Schema.optional(
|
time: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
start: Schema.Number,
|
start: NonNegativeInt,
|
||||||
end: Schema.optional(Schema.Number),
|
end: Schema.optional(NonNegativeInt),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||||
@@ -132,8 +132,8 @@ export const ReasoningPart = Schema.Struct({
|
|||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
start: Schema.Number,
|
start: NonNegativeInt,
|
||||||
end: Schema.optional(Schema.Number),
|
end: Schema.optional(NonNegativeInt),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "ReasoningPart" })
|
.annotate({ identifier: "ReasoningPart" })
|
||||||
@@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable<Schema.Schema.Type<typeof Reasonin
|
|||||||
const filePartSourceBase = {
|
const filePartSourceBase = {
|
||||||
text: Schema.Struct({
|
text: Schema.Struct({
|
||||||
value: Schema.String,
|
value: Schema.String,
|
||||||
start: Schema.Int,
|
start: NonNegativeInt,
|
||||||
end: Schema.Int,
|
end: NonNegativeInt,
|
||||||
}).annotate({ identifier: "FilePartSourceText" }),
|
}).annotate({ identifier: "FilePartSourceText" }),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ export const SymbolSource = Schema.Struct({
|
|||||||
path: Schema.String,
|
path: Schema.String,
|
||||||
range: LSP.Range,
|
range: LSP.Range,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
kind: Schema.Int,
|
kind: NonNegativeInt,
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "SymbolSource" })
|
.annotate({ identifier: "SymbolSource" })
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
@@ -201,8 +201,8 @@ export const AgentPart = Schema.Struct({
|
|||||||
source: Schema.optional(
|
source: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
value: Schema.String,
|
value: Schema.String,
|
||||||
start: Schema.Int,
|
start: NonNegativeInt,
|
||||||
end: Schema.Int,
|
end: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -242,11 +242,10 @@ export type SubtaskPart = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPar
|
|||||||
export const RetryPart = Schema.Struct({
|
export const RetryPart = Schema.Struct({
|
||||||
...partBase,
|
...partBase,
|
||||||
type: Schema.Literal("retry"),
|
type: Schema.Literal("retry"),
|
||||||
attempt: Schema.Number,
|
attempt: NonNegativeInt,
|
||||||
// APIError is still NamedError-based Zod; bridge via ZodOverride until errors migrate.
|
error: APIError.EffectSchema,
|
||||||
error: Schema.Any.annotate({ [ZodOverride]: APIError.Schema }),
|
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
created: Schema.Number,
|
created: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "RetryPart" })
|
.annotate({ identifier: "RetryPart" })
|
||||||
@@ -269,15 +268,15 @@ export const StepFinishPart = Schema.Struct({
|
|||||||
type: Schema.Literal("step-finish"),
|
type: Schema.Literal("step-finish"),
|
||||||
reason: Schema.String,
|
reason: Schema.String,
|
||||||
snapshot: Schema.optional(Schema.String),
|
snapshot: Schema.optional(Schema.String),
|
||||||
cost: Schema.Number,
|
cost: Schema.Finite,
|
||||||
tokens: Schema.Struct({
|
tokens: Schema.Struct({
|
||||||
total: Schema.optional(Schema.Number),
|
total: Schema.optional(NonNegativeInt),
|
||||||
input: Schema.Number,
|
input: NonNegativeInt,
|
||||||
output: Schema.Number,
|
output: NonNegativeInt,
|
||||||
reasoning: Schema.Number,
|
reasoning: NonNegativeInt,
|
||||||
cache: Schema.Struct({
|
cache: Schema.Struct({
|
||||||
read: Schema.Number,
|
read: NonNegativeInt,
|
||||||
write: Schema.Number,
|
write: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -300,7 +299,7 @@ export const ToolStateRunning = Schema.Struct({
|
|||||||
title: Schema.optional(Schema.String),
|
title: Schema.optional(Schema.String),
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
start: Schema.Number,
|
start: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "ToolStateRunning" })
|
.annotate({ identifier: "ToolStateRunning" })
|
||||||
@@ -314,9 +313,9 @@ export const ToolStateCompleted = Schema.Struct({
|
|||||||
title: Schema.String,
|
title: Schema.String,
|
||||||
metadata: Schema.Record(Schema.String, Schema.Any),
|
metadata: Schema.Record(Schema.String, Schema.Any),
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
start: Schema.Number,
|
start: NonNegativeInt,
|
||||||
end: Schema.Number,
|
end: NonNegativeInt,
|
||||||
compacted: Schema.optional(Schema.Number),
|
compacted: Schema.optional(NonNegativeInt),
|
||||||
}),
|
}),
|
||||||
attachments: Schema.optional(Schema.Array(FilePart)),
|
attachments: Schema.optional(Schema.Array(FilePart)),
|
||||||
})
|
})
|
||||||
@@ -336,8 +335,8 @@ export const ToolStateError = Schema.Struct({
|
|||||||
error: Schema.String,
|
error: Schema.String,
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
start: Schema.Number,
|
start: NonNegativeInt,
|
||||||
end: Schema.Number,
|
end: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "ToolStateError" })
|
.annotate({ identifier: "ToolStateError" })
|
||||||
@@ -380,7 +379,7 @@ export const User = Schema.Struct({
|
|||||||
...messageBase,
|
...messageBase,
|
||||||
role: Schema.Literal("user"),
|
role: Schema.Literal("user"),
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
created: Schema.Number,
|
created: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
format: Schema.optional(_Format),
|
format: Schema.optional(_Format),
|
||||||
summary: Schema.optional(
|
summary: Schema.optional(
|
||||||
@@ -447,9 +446,7 @@ export type Part =
|
|||||||
| RetryPart
|
| RetryPart
|
||||||
| CompactionPart
|
| CompactionPart
|
||||||
|
|
||||||
// Errors are still NamedError-based Zod; bridge via ZodOverride so the derived
|
// Zod discriminated union kept for the legacy Hono OpenAPI path.
|
||||||
// Zod + JSON Schema emit the original discriminatedUnion shape. Migrating the
|
|
||||||
// error classes to Schema.TaggedErrorClass is a separate slice.
|
|
||||||
const AssistantErrorZod = z.discriminatedUnion("name", [
|
const AssistantErrorZod = z.discriminatedUnion("name", [
|
||||||
AuthError.Schema,
|
AuthError.Schema,
|
||||||
NamedError.Unknown.Schema,
|
NamedError.Unknown.Schema,
|
||||||
@@ -461,6 +458,17 @@ const AssistantErrorZod = z.discriminatedUnion("name", [
|
|||||||
])
|
])
|
||||||
type AssistantError = z.infer<typeof AssistantErrorZod>
|
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 ─────────────────────────────────────────────────────
|
// ── Prompt input schemas ─────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the
|
// Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the
|
||||||
@@ -477,8 +485,8 @@ export const TextPartInput = Schema.Struct({
|
|||||||
ignored: Schema.optional(Schema.Boolean),
|
ignored: Schema.optional(Schema.Boolean),
|
||||||
time: Schema.optional(
|
time: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
start: Schema.Number,
|
start: NonNegativeInt,
|
||||||
end: Schema.optional(Schema.Number),
|
end: Schema.optional(NonNegativeInt),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||||
@@ -506,8 +514,8 @@ export const AgentPartInput = Schema.Struct({
|
|||||||
source: Schema.optional(
|
source: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
value: Schema.String,
|
value: Schema.String,
|
||||||
start: Schema.Int,
|
start: NonNegativeInt,
|
||||||
end: Schema.Int,
|
end: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -537,10 +545,10 @@ export const Assistant = Schema.Struct({
|
|||||||
...messageBase,
|
...messageBase,
|
||||||
role: Schema.Literal("assistant"),
|
role: Schema.Literal("assistant"),
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
created: Schema.Number,
|
created: NonNegativeInt,
|
||||||
completed: Schema.optional(Schema.Number),
|
completed: Schema.optional(NonNegativeInt),
|
||||||
}),
|
}),
|
||||||
error: Schema.optional(Schema.Any.annotate({ [ZodOverride]: AssistantErrorZod })),
|
error: Schema.optional(AssistantErrorSchema),
|
||||||
parentID: MessageID,
|
parentID: MessageID,
|
||||||
modelID: ModelID,
|
modelID: ModelID,
|
||||||
providerID: ProviderID,
|
providerID: ProviderID,
|
||||||
@@ -554,15 +562,15 @@ export const Assistant = Schema.Struct({
|
|||||||
root: Schema.String,
|
root: Schema.String,
|
||||||
}),
|
}),
|
||||||
summary: Schema.optional(Schema.Boolean),
|
summary: Schema.optional(Schema.Boolean),
|
||||||
cost: Schema.Number,
|
cost: Schema.Finite,
|
||||||
tokens: Schema.Struct({
|
tokens: Schema.Struct({
|
||||||
total: Schema.optional(Schema.Number),
|
total: Schema.optional(NonNegativeInt),
|
||||||
input: Schema.Number,
|
input: NonNegativeInt,
|
||||||
output: Schema.Number,
|
output: NonNegativeInt,
|
||||||
reasoning: Schema.Number,
|
reasoning: NonNegativeInt,
|
||||||
cache: Schema.Struct({
|
cache: Schema.Struct({
|
||||||
read: Schema.Number,
|
read: NonNegativeInt,
|
||||||
write: Schema.Number,
|
write: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
structured: Schema.optional(Schema.Any),
|
structured: Schema.optional(Schema.Any),
|
||||||
@@ -594,7 +602,7 @@ const RemovedEventSchema = Schema.Struct({
|
|||||||
const PartUpdatedEventSchema = Schema.Struct({
|
const PartUpdatedEventSchema = Schema.Struct({
|
||||||
sessionID: SessionID,
|
sessionID: SessionID,
|
||||||
part: _Part,
|
part: _Part,
|
||||||
time: Schema.Number,
|
time: NonNegativeInt,
|
||||||
})
|
})
|
||||||
|
|
||||||
const PartRemovedEventSchema = Schema.Struct({
|
const PartRemovedEventSchema = Schema.Struct({
|
||||||
@@ -651,7 +659,7 @@ export type WithParts = {
|
|||||||
|
|
||||||
const Cursor = Schema.Struct({
|
const Cursor = Schema.Struct({
|
||||||
id: MessageID,
|
id: MessageID,
|
||||||
time: Schema.Number,
|
time: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||||
})
|
})
|
||||||
type Cursor = typeof Cursor.Type
|
type Cursor = typeof Cursor.Type
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Schema } from "effect"
|
|||||||
import { SessionID } from "./schema"
|
import { SessionID } from "./schema"
|
||||||
import { ModelID, ProviderID } from "../provider/schema"
|
import { ModelID, ProviderID } from "../provider/schema"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { withStatics } from "@/util/schema"
|
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||||
import { namedSchemaError } from "@/util/named-schema-error"
|
import { namedSchemaError } from "@/util/named-schema-error"
|
||||||
|
|
||||||
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
|
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
|
||||||
@@ -33,7 +33,7 @@ const UnknownErrorEffect = Schema.Struct({
|
|||||||
|
|
||||||
export const ToolCall = Schema.Struct({
|
export const ToolCall = Schema.Struct({
|
||||||
state: Schema.Literal("call"),
|
state: Schema.Literal("call"),
|
||||||
step: Schema.optional(Schema.Number),
|
step: Schema.optional(NonNegativeInt),
|
||||||
toolCallId: Schema.String,
|
toolCallId: Schema.String,
|
||||||
toolName: Schema.String,
|
toolName: Schema.String,
|
||||||
args: Schema.Unknown,
|
args: Schema.Unknown,
|
||||||
@@ -44,7 +44,7 @@ export type ToolCall = Schema.Schema.Type<typeof ToolCall>
|
|||||||
|
|
||||||
export const ToolPartialCall = Schema.Struct({
|
export const ToolPartialCall = Schema.Struct({
|
||||||
state: Schema.Literal("partial-call"),
|
state: Schema.Literal("partial-call"),
|
||||||
step: Schema.optional(Schema.Number),
|
step: Schema.optional(NonNegativeInt),
|
||||||
toolCallId: Schema.String,
|
toolCallId: Schema.String,
|
||||||
toolName: Schema.String,
|
toolName: Schema.String,
|
||||||
args: Schema.Unknown,
|
args: Schema.Unknown,
|
||||||
@@ -55,7 +55,7 @@ export type ToolPartialCall = Schema.Schema.Type<typeof ToolPartialCall>
|
|||||||
|
|
||||||
export const ToolResult = Schema.Struct({
|
export const ToolResult = Schema.Struct({
|
||||||
state: Schema.Literal("result"),
|
state: Schema.Literal("result"),
|
||||||
step: Schema.optional(Schema.Number),
|
step: Schema.optional(NonNegativeInt),
|
||||||
toolCallId: Schema.String,
|
toolCallId: Schema.String,
|
||||||
toolName: Schema.String,
|
toolName: Schema.String,
|
||||||
args: Schema.Unknown,
|
args: Schema.Unknown,
|
||||||
@@ -141,8 +141,8 @@ export const Info = Schema.Struct({
|
|||||||
parts: Schema.Array(MessagePart),
|
parts: Schema.Array(MessagePart),
|
||||||
metadata: Schema.Struct({
|
metadata: Schema.Struct({
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
created: Schema.Number,
|
created: NonNegativeInt,
|
||||||
completed: Schema.optional(Schema.Number),
|
completed: Schema.optional(NonNegativeInt),
|
||||||
}),
|
}),
|
||||||
error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])),
|
error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])),
|
||||||
sessionID: SessionID,
|
sessionID: SessionID,
|
||||||
@@ -153,8 +153,8 @@ export const Info = Schema.Struct({
|
|||||||
title: Schema.String,
|
title: Schema.String,
|
||||||
snapshot: Schema.optional(Schema.String),
|
snapshot: Schema.optional(Schema.String),
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
start: Schema.Number,
|
start: NonNegativeInt,
|
||||||
end: Schema.Number,
|
end: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
@@ -169,15 +169,15 @@ export const Info = Schema.Struct({
|
|||||||
cwd: Schema.String,
|
cwd: Schema.String,
|
||||||
root: Schema.String,
|
root: Schema.String,
|
||||||
}),
|
}),
|
||||||
cost: Schema.Number,
|
cost: Schema.Finite,
|
||||||
summary: Schema.optional(Schema.Boolean),
|
summary: Schema.optional(Schema.Boolean),
|
||||||
tokens: Schema.Struct({
|
tokens: Schema.Struct({
|
||||||
input: Schema.Number,
|
input: NonNegativeInt,
|
||||||
output: Schema.Number,
|
output: NonNegativeInt,
|
||||||
reasoning: Schema.Number,
|
reasoning: NonNegativeInt,
|
||||||
cache: Schema.Struct({
|
cache: Schema.Struct({
|
||||||
read: Schema.Number,
|
read: NonNegativeInt,
|
||||||
write: Schema.Number,
|
write: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import { Permission } from "@/permission"
|
|||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Effect, Layer, Option, Context, Schema, Types } from "effect"
|
import { Effect, Layer, Option, Context, Schema, Types } from "effect"
|
||||||
import { zod } from "@/util/effect-zod"
|
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" })
|
const log = Log.create({ service: "session" })
|
||||||
|
|
||||||
@@ -132,9 +132,9 @@ function sessionPath(worktree: string, cwd: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Summary = Schema.Struct({
|
const Summary = Schema.Struct({
|
||||||
additions: Schema.Number,
|
additions: NonNegativeInt,
|
||||||
deletions: Schema.Number,
|
deletions: NonNegativeInt,
|
||||||
files: Schema.Number,
|
files: NonNegativeInt,
|
||||||
diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)),
|
diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -143,10 +143,10 @@ const Share = Schema.Struct({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const Time = Schema.Struct({
|
const Time = Schema.Struct({
|
||||||
created: Schema.Number,
|
created: NonNegativeInt,
|
||||||
updated: Schema.Number,
|
updated: NonNegativeInt,
|
||||||
compacting: optionalOmitUndefined(Schema.Number),
|
compacting: optionalOmitUndefined(NonNegativeInt),
|
||||||
archived: optionalOmitUndefined(Schema.Number),
|
archived: optionalOmitUndefined(NonNegativeInt),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Revert = Schema.Struct({
|
const Revert = Schema.Struct({
|
||||||
@@ -215,7 +215,7 @@ export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema
|
|||||||
)
|
)
|
||||||
export const SetArchivedInput = Schema.Struct({
|
export const SetArchivedInput = Schema.Struct({
|
||||||
sessionID: SessionID,
|
sessionID: SessionID,
|
||||||
time: Schema.optional(Schema.Number),
|
time: Schema.optional(NonNegativeInt),
|
||||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
export const SetPermissionInput = Schema.Struct({
|
export const SetPermissionInput = Schema.Struct({
|
||||||
sessionID: SessionID,
|
sessionID: SessionID,
|
||||||
@@ -228,7 +228,7 @@ export const SetRevertInput = Schema.Struct({
|
|||||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
export const MessagesInput = Schema.Struct({
|
export const MessagesInput = Schema.Struct({
|
||||||
sessionID: SessionID,
|
sessionID: SessionID,
|
||||||
limit: Schema.optional(Schema.Number),
|
limit: Schema.optional(NonNegativeInt),
|
||||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
|
||||||
const CreatedEventSchema = Schema.Struct({
|
const CreatedEventSchema = Schema.Struct({
|
||||||
@@ -241,10 +241,10 @@ const UpdatedShare = Schema.Struct({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const UpdatedTime = Schema.Struct({
|
const UpdatedTime = Schema.Struct({
|
||||||
created: Schema.optional(Schema.NullOr(Schema.Number)),
|
created: Schema.optional(Schema.NullOr(NonNegativeInt)),
|
||||||
updated: Schema.optional(Schema.NullOr(Schema.Number)),
|
updated: Schema.optional(Schema.NullOr(NonNegativeInt)),
|
||||||
compacting: Schema.optional(Schema.NullOr(Schema.Number)),
|
compacting: Schema.optional(Schema.NullOr(NonNegativeInt)),
|
||||||
archived: Schema.optional(Schema.NullOr(Schema.Number)),
|
archived: Schema.optional(Schema.NullOr(NonNegativeInt)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const UpdatedInfo = Schema.Struct({
|
const UpdatedInfo = Schema.Struct({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Bus } from "@/bus"
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { SessionID } from "./schema"
|
import { SessionID } from "./schema"
|
||||||
import { zod } from "@/util/effect-zod"
|
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 { Effect, Layer, Context, Schema } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
@@ -13,9 +13,9 @@ export const Info = Schema.Union([
|
|||||||
}),
|
}),
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.Literal("retry"),
|
type: Schema.Literal("retry"),
|
||||||
attempt: Schema.Number,
|
attempt: NonNegativeInt,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
next: Schema.Number,
|
next: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.Literal("busy"),
|
type: Schema.Literal("busy"),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Hash } from "@opencode-ai/core/util/hash"
|
|||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
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"
|
import { zod } from "@/util/effect-zod"
|
||||||
|
|
||||||
export const Patch = Schema.Struct({
|
export const Patch = Schema.Struct({
|
||||||
@@ -22,8 +22,8 @@ export type Patch = typeof Patch.Type
|
|||||||
export const FileDiff = Schema.Struct({
|
export const FileDiff = Schema.Struct({
|
||||||
file: Schema.String,
|
file: Schema.String,
|
||||||
patch: Schema.String,
|
patch: Schema.String,
|
||||||
additions: Schema.Number,
|
additions: NonNegativeInt,
|
||||||
deletions: Schema.Number,
|
deletions: NonNegativeInt,
|
||||||
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
|
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "SnapshotFileDiff" })
|
.annotate({ identifier: "SnapshotFileDiff" })
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { NamedError } from "@opencode-ai/core/util/error"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
|
import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
|
||||||
|
import { NonNegativeInt } from "@/util/schema"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
|
|
||||||
const log = Log.create({ service: "storage" })
|
const log = Log.create({ service: "storage" })
|
||||||
@@ -41,8 +42,8 @@ const MessageFile = Schema.Struct({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const DiffFile = Schema.Struct({
|
const DiffFile = Schema.Struct({
|
||||||
additions: Schema.Number,
|
additions: NonNegativeInt,
|
||||||
deletions: Schema.Number,
|
deletions: NonNegativeInt,
|
||||||
})
|
})
|
||||||
|
|
||||||
const SummaryFile = Schema.Struct({
|
const SummaryFile = Schema.Struct({
|
||||||
|
|||||||
@@ -294,4 +294,20 @@ export function payloads() {
|
|||||||
.toArray()
|
.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 "."
|
export * as SyncEvent from "."
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
import { PositiveInt } from "@/util/schema"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { createWriteStream } from "node:fs"
|
import { createWriteStream } from "node:fs"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
@@ -53,7 +54,7 @@ const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurs
|
|||||||
|
|
||||||
export const Parameters = Schema.Struct({
|
export const Parameters = Schema.Struct({
|
||||||
command: Schema.String.annotate({ description: "The command to execute" }),
|
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({
|
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.`,
|
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:
|
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'",
|
"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))
|
.check(Schema.isLessThanOrEqualTo(50000))
|
||||||
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000)))
|
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000)))
|
||||||
.annotate({
|
.annotate({
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ const operations = [
|
|||||||
export const Parameters = Schema.Struct({
|
export const Parameters = Schema.Struct({
|
||||||
operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }),
|
operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }),
|
||||||
filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }),
|
filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }),
|
||||||
line: Schema.Number.check(Schema.isInt())
|
line: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({
|
||||||
.check(Schema.isGreaterThanOrEqualTo(1))
|
description: "The line number (1-based, as shown in editors)",
|
||||||
.annotate({ description: "The line number (1-based, as shown in editors)" }),
|
}),
|
||||||
character: Schema.Number.check(Schema.isInt())
|
character: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({
|
||||||
.check(Schema.isGreaterThanOrEqualTo(1))
|
description: "The character offset (1-based, as shown in editors)",
|
||||||
.annotate({ description: "The character offset (1-based, as shown in editors)" }),
|
}),
|
||||||
query: Schema.optional(Schema.String).annotate({
|
query: Schema.optional(Schema.String).annotate({
|
||||||
description: "Search query for workspaceSymbol. Empty string requests all symbols.",
|
description: "Search query for workspaceSymbol. Empty string requests all symbols.",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Effect, Option, Schema, Scope } from "effect"
|
import { Effect, Option, Schema, Scope } from "effect"
|
||||||
|
import { NonNegativeInt } from "@/util/schema"
|
||||||
import { createReadStream } from "fs"
|
import { createReadStream } from "fs"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { createInterface } from "readline"
|
import { createInterface } from "readline"
|
||||||
@@ -25,10 +26,10 @@ const SAMPLE_BYTES = 4096
|
|||||||
// unchanged; purely CLI-facing uses must now send numbers rather than strings.
|
// unchanged; purely CLI-facing uses must now send numbers rather than strings.
|
||||||
export const Parameters = Schema.Struct({
|
export const Parameters = Schema.Struct({
|
||||||
filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }),
|
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)",
|
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)",
|
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 })
|
.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>
|
type Data = Schema.Schema.Type<typeof dataSchema>
|
||||||
|
|
||||||
class NamedSchemaError extends Error {
|
class NamedSchemaError extends Error {
|
||||||
static readonly Schema = wire
|
static readonly Schema = wire
|
||||||
|
static readonly EffectSchema = effectSchema
|
||||||
static readonly tag = tag
|
static readonly tag = tag
|
||||||
public static isInstance(input: unknown): input is NamedSchemaError {
|
public static isInstance(input: unknown): input is NamedSchemaError {
|
||||||
return typeof input === "object" && input !== null && "name" in input && (input as { name: unknown }).name === tag
|
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))
|
export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional public JSON field that can hold explicit `undefined` on the type
|
* Optional public JSON field that can hold explicit `undefined` on the type
|
||||||
* side but encodes it as an omitted key, matching legacy `JSON.stringify`.
|
* side but encodes it as an omitted key, matching legacy `JSON.stringify`.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
import { NonNegativeInt } from "@/util/schema"
|
||||||
import { SessionEvent } from "./session-event"
|
import { SessionEvent } from "./session-event"
|
||||||
|
|
||||||
export const ID = SessionEvent.ID
|
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")({
|
export class AssistantRetry extends Schema.Class<AssistantRetry>("Session.Entry.Assistant.Retry")({
|
||||||
attempt: Schema.Number,
|
attempt: NonNegativeInt,
|
||||||
error: SessionEvent.RetryError,
|
error: SessionEvent.RetryError,
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
created: Schema.DateTimeUtc,
|
created: Schema.DateTimeUtc,
|
||||||
@@ -132,14 +133,14 @@ export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant"
|
|||||||
type: Schema.Literal("assistant"),
|
type: Schema.Literal("assistant"),
|
||||||
content: AssistantContent.pipe(Schema.Array),
|
content: AssistantContent.pipe(Schema.Array),
|
||||||
retries: AssistantRetry.pipe(Schema.Array, Schema.optional),
|
retries: AssistantRetry.pipe(Schema.Array, Schema.optional),
|
||||||
cost: Schema.Number.pipe(Schema.optional),
|
cost: Schema.Finite.pipe(Schema.optional),
|
||||||
tokens: Schema.Struct({
|
tokens: Schema.Struct({
|
||||||
input: Schema.Number,
|
input: NonNegativeInt,
|
||||||
output: Schema.Number,
|
output: NonNegativeInt,
|
||||||
reasoning: Schema.Number,
|
reasoning: NonNegativeInt,
|
||||||
cache: Schema.Struct({
|
cache: Schema.Struct({
|
||||||
read: Schema.Number,
|
read: NonNegativeInt,
|
||||||
write: Schema.Number,
|
write: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
}).pipe(Schema.optional),
|
}).pipe(Schema.optional),
|
||||||
error: Schema.String.pipe(Schema.optional),
|
error: Schema.String.pipe(Schema.optional),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
import { withStatics } from "@/util/schema"
|
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||||
import * as DateTime from "effect/DateTime"
|
import * as DateTime from "effect/DateTime"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@ export namespace SessionEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Source extends Schema.Class<Source>("Session.Event.Source")({
|
export class Source extends Schema.Class<Source>("Session.Event.Source")({
|
||||||
start: Schema.Number,
|
start: NonNegativeInt,
|
||||||
end: Schema.Number,
|
end: NonNegativeInt,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export namespace SessionEvent {
|
|||||||
|
|
||||||
export class RetryError extends Schema.Class<RetryError>("Session.Event.Retry.Error")({
|
export class RetryError extends Schema.Class<RetryError>("Session.Event.Retry.Error")({
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
statusCode: Schema.Number.pipe(Schema.optional),
|
statusCode: NonNegativeInt.pipe(Schema.optional),
|
||||||
isRetryable: Schema.Boolean,
|
isRetryable: Schema.Boolean,
|
||||||
responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||||
responseBody: Schema.String.pipe(Schema.optional),
|
responseBody: Schema.String.pipe(Schema.optional),
|
||||||
@@ -123,14 +123,14 @@ export namespace SessionEvent {
|
|||||||
...Base,
|
...Base,
|
||||||
type: Schema.Literal("step.ended"),
|
type: Schema.Literal("step.ended"),
|
||||||
reason: Schema.String,
|
reason: Schema.String,
|
||||||
cost: Schema.Number,
|
cost: Schema.Finite,
|
||||||
tokens: Schema.Struct({
|
tokens: Schema.Struct({
|
||||||
input: Schema.Number,
|
input: NonNegativeInt,
|
||||||
output: Schema.Number,
|
output: NonNegativeInt,
|
||||||
reasoning: Schema.Number,
|
reasoning: NonNegativeInt,
|
||||||
cache: Schema.Struct({
|
cache: Schema.Struct({
|
||||||
read: Schema.Number,
|
read: NonNegativeInt,
|
||||||
write: Schema.Number,
|
write: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}) {
|
}) {
|
||||||
@@ -395,7 +395,7 @@ export namespace SessionEvent {
|
|||||||
export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
|
export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
|
||||||
...Base,
|
...Base,
|
||||||
type: Schema.Literal("retried"),
|
type: Schema.Literal("retried"),
|
||||||
attempt: Schema.Number,
|
attempt: NonNegativeInt,
|
||||||
error: RetryError,
|
error: RetryError,
|
||||||
}) {
|
}) {
|
||||||
static create(input: BaseInput & { attempt: number; error: RetryError }) {
|
static create(input: BaseInput & { attempt: number; error: RetryError }) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { ControlPaths } from "../../src/server/routes/instance/httpapi/control"
|
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
|
||||||
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file"
|
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||||
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/global"
|
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
|
||||||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||||
import { Server } from "../../src/server/server"
|
import { Server } from "../../src/server/server"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
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(
|
return Object.fromEntries(
|
||||||
Object.entries(spec.paths).flatMap(([path, item]) =>
|
Object.entries(spec.paths).flatMap(([path, item]) =>
|
||||||
methods
|
methods
|
||||||
.filter((method) => item[method])
|
.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 = {
|
type Operation = {
|
||||||
parameters?: unknown[]
|
parameters?: unknown[]
|
||||||
responses?: unknown
|
responses?: unknown
|
||||||
@@ -74,7 +90,7 @@ type Operation = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RequestBody = {
|
type RequestBody = {
|
||||||
content?: Record<string, { schema?: { $ref?: string; type?: string } }>
|
content?: Record<string, { schema?: OpenApiSchema }>
|
||||||
required?: boolean
|
required?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,17 +113,27 @@ function parameterSchema(input: {
|
|||||||
return param.schema
|
return param.schema
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestBodyKey(body: unknown) {
|
function requestBodyKey(spec: OpenApiSpec, body: unknown) {
|
||||||
if (!body || typeof body !== "object" || !("content" in body)) return ""
|
if (!body || typeof body !== "object" || !("content" in body)) return ""
|
||||||
const requestBody = body as RequestBody
|
const requestBody = body as RequestBody
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
required: requestBody.required === true,
|
required: requestBody.required === true,
|
||||||
content: Object.entries(requestBody.content ?? {})
|
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(),
|
.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: {
|
function responseContentTypes(input: {
|
||||||
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
|
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
|
||||||
path: string
|
path: string
|
||||||
@@ -146,6 +172,14 @@ afterEach(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("HttpApi server", () => {
|
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 () => {
|
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
|
||||||
const honoRoutes = openApiRouteKeys(await Server.openapi())
|
const honoRoutes = openApiRouteKeys(await Server.openapi())
|
||||||
const effectRoutes = openApiRouteKeys(effectOpenApi())
|
const effectRoutes = openApiRouteKeys(effectOpenApi())
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
|||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Server } from "../../src/server/server"
|
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 { Session } from "@/session/session"
|
||||||
import { Database } from "@/storage/db"
|
import { Database } from "@/storage/db"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
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 { Context } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
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 { Instance } from "../../src/project/instance"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
|||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Server } from "../../src/server/server"
|
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 * as Log from "@opencode-ai/core/util/log"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
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 { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Server } from "../../src/server/server"
|
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 { SessionPaths } from "../../src/server/routes/instance/httpapi/session"
|
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||||
import { MessageID, PartID } from "../../src/session/schema"
|
import { MessageID, PartID } from "../../src/session/schema"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
@@ -19,7 +19,7 @@ const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
|||||||
|
|
||||||
function app(experimental: boolean) {
|
function app(experimental: boolean) {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||||
return Server.Default().app
|
return experimental ? Server.Default().app : Server.Legacy().app
|
||||||
}
|
}
|
||||||
type TestApp = ReturnType<typeof 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 { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
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 { Instance } from "../../src/project/instance"
|
||||||
import { Server } from "../../src/server/server"
|
import { Server } from "../../src/server/server"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
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) {
|
function app(experimental: boolean) {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||||
return Server.Default().app
|
return experimental ? Server.Default().app : Server.Legacy().app
|
||||||
}
|
}
|
||||||
type TestApp = ReturnType<typeof app>
|
type TestApp = ReturnType<typeof app>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const oauthInstructions = "Finish OAuth"
|
|||||||
|
|
||||||
function app(experimental: boolean) {
|
function app(experimental: boolean) {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||||
return Server.Default().app
|
return experimental ? Server.Default().app : Server.Legacy().app
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestAuthorize(input: {
|
function requestAuthorize(input: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
|||||||
import { PtyID } from "../../src/pty/schema"
|
import { PtyID } from "../../src/pty/schema"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Server } from "../../src/server/server"
|
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 * as Log from "@opencode-ai/core/util/log"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|||||||
@@ -1,40 +1,209 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
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 path from "path"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
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) {
|
function app(backend: Backend, input?: { password?: string; username?: string }) {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi"
|
||||||
const handler = ExperimentalHttpApiServer.webHandler().handler
|
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(
|
const fetch = Object.assign(
|
||||||
(request: RequestInfo | URL, init?: RequestInit) =>
|
async (request: RequestInfo | URL, init?: RequestInit) =>
|
||||||
handler(new Request(request, init), ExperimentalHttpApiServer.context),
|
await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
|
||||||
{ preconnect: globalThis.fetch.preconnect },
|
{ preconnect: globalThis.fetch.preconnect },
|
||||||
) satisfies typeof globalThis.fetch
|
) satisfies typeof globalThis.fetch
|
||||||
return createOpencodeClient({
|
return createOpencodeClient({
|
||||||
baseUrl: "http://localhost",
|
baseUrl: "http://localhost",
|
||||||
directory,
|
directory,
|
||||||
|
headers: input?.headers,
|
||||||
fetch,
|
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) {
|
async function expectStatus(result: Promise<{ response: Response }>, status: number) {
|
||||||
expect((await result).response.status).toBe(status)
|
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 () => {
|
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()
|
await resetDatabase()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("HttpApi SDK", () => {
|
describe("HttpApi SDK", () => {
|
||||||
test("uses the generated SDK for global and control routes", async () => {
|
test("uses the generated SDK for global and control routes", async () => {
|
||||||
const sdk = client()
|
const sdk = client("httpapi")
|
||||||
const health = await sdk.global.health()
|
const health = await sdk.global.health()
|
||||||
|
|
||||||
expect(health.response.status).toBe(200)
|
expect(health.response.status).toBe(200)
|
||||||
@@ -60,7 +229,7 @@ describe("HttpApi SDK", () => {
|
|||||||
config: { formatter: false, lsp: false },
|
config: { formatter: false, lsp: false },
|
||||||
init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello"),
|
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" })
|
const file = await sdk.file.read({ path: "hello.txt" })
|
||||||
expect(file.response.status).toBe(200)
|
expect(file.response.status).toBe(200)
|
||||||
@@ -81,4 +250,381 @@ describe("HttpApi SDK", () => {
|
|||||||
expectStatus(sdk.find.files({ query: "hello", limit: 10 }), 200),
|
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 { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Server } from "../../src/server/server"
|
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 { Session } from "@/session/session"
|
||||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||||
import { MessageV2 } from "../../src/session/message-v2"
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Effect } from "effect"
|
|||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Server } from "../../src/server/server"
|
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 { Session } from "@/session/session"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
@@ -16,7 +16,7 @@ const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
|||||||
|
|
||||||
function app(httpapi = true) {
|
function app(httpapi = true) {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi
|
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>) {
|
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 { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { SessionID } from "../../src/session/schema"
|
import { SessionID } from "../../src/session/schema"
|
||||||
import { Instance } from "../../src/project/instance"
|
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 { callTui } from "../../src/server/routes/instance/tui"
|
||||||
import { Server } from "../../src/server/server"
|
import { Server } from "../../src/server/server"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
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 { mkdir } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
@@ -6,13 +6,14 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
|||||||
import { registerAdaptor } from "../../src/control-plane/adaptors"
|
import { registerAdaptor } from "../../src/control-plane/adaptors"
|
||||||
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
|
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
|
||||||
import { Workspace } from "../../src/control-plane/workspace"
|
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 { Session } from "@/session/session"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { Server } from "../../src/server/server"
|
import { Server } from "../../src/server/server"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||||
|
|
||||||
void Log.init({ print: false })
|
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 () => {
|
afterEach(async () => {
|
||||||
|
mock.restore()
|
||||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
@@ -125,4 +160,81 @@ describe("workspace HttpApi", () => {
|
|||||||
expect(listed.status).toBe(200)
|
expect(listed.status).toBe(200)
|
||||||
expect(await listed.json()).toEqual([])
|
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": {
|
"timeout": {
|
||||||
"description": "Optional timeout in milliseconds",
|
"description": "Optional timeout in milliseconds",
|
||||||
"type": "number",
|
"exclusiveMinimum": 0,
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"type": "integer",
|
||||||
},
|
},
|
||||||
"workdir": {
|
"workdir": {
|
||||||
"description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.",
|
"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.",
|
"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,
|
"maximum": 50000,
|
||||||
"minimum": 1000,
|
"minimum": 1000,
|
||||||
"type": "number",
|
"type": "integer",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -224,7 +226,6 @@ exports[`tool parameters JSON Schema (wire shape) lsp 1`] = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
||||||
exports[`tool parameters JSON Schema (wire shape) plan 1`] = `
|
exports[`tool parameters JSON Schema (wire shape) plan 1`] = `
|
||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
@@ -304,11 +305,15 @@ exports[`tool parameters JSON Schema (wire shape) read 1`] = `
|
|||||||
},
|
},
|
||||||
"limit": {
|
"limit": {
|
||||||
"description": "The maximum number of lines to read (defaults to 2000)",
|
"description": "The maximum number of lines to read (defaults to 2000)",
|
||||||
"type": "number",
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer",
|
||||||
},
|
},
|
||||||
"offset": {
|
"offset": {
|
||||||
"description": "The line number to start reading from (1-indexed)",
|
"description": "The line number to start reading from (1-indexed)",
|
||||||
"type": "number",
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
Reference in New Issue
Block a user