mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-22 17:46:14 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dd6600afc |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
|
||||
@@ -365,6 +365,7 @@
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/shell-scan": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
@@ -629,7 +630,6 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -734,6 +734,15 @@
|
||||
"vite": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/shell-scan": {
|
||||
"name": "@opencode-ai/shell-scan",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode-ai/simulation",
|
||||
"version": "1.17.13",
|
||||
@@ -2054,6 +2063,8 @@
|
||||
|
||||
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
|
||||
|
||||
"@opencode-ai/shell-scan": ["@opencode-ai/shell-scan@workspace:packages/shell-scan"],
|
||||
|
||||
"@opencode-ai/simulation": ["@opencode-ai/simulation@workspace:packages/simulation"],
|
||||
|
||||
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-xRvq8FkSjn+1q+1wcab+jAmQJdo8lHF8OGnzU+xPLyI=",
|
||||
"aarch64-linux": "sha256-uAwwtOz81LLimTqiDH6E1W65srSMLGk1QV0cUJYanj0=",
|
||||
"aarch64-darwin": "sha256-F28kZvtYRb+ri5T8JoIG/VMK6J7ad5R3QivwJDLtmWA=",
|
||||
"x86_64-darwin": "sha256-NWNoBjwQTTNAESdFWItT4UYc+fTLD1RIe/Ibbqtem7Q="
|
||||
"x86_64-linux": "sha256-PatsUdaitHvSUpS5gkC5J2rsUNB5vwJKqHdlOFaKk70=",
|
||||
"aarch64-linux": "sha256-gTRQMAADH/SpQ8yh+YS2IcnmQxnJcU4FUah2YfrKeP8=",
|
||||
"aarch64-darwin": "sha256-QTqlwmugYh+iu5Sh/Hxv01NXH/OhzcQ8ObVUbA9A8AM=",
|
||||
"x86_64-darwin": "sha256-0DPAbNCVw2nUMWkIGEhB6saMdxRRwAJi7wAoWCQc7xQ="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export const model = (input: ModelInput) => {
|
||||
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
|
||||
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
|
||||
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
|
||||
return Effect.undefined
|
||||
return Effect.succeed(undefined)
|
||||
})
|
||||
const multipartMask =
|
||||
mask === undefined
|
||||
|
||||
@@ -16,7 +16,7 @@ export const decodeDataUrl = (
|
||||
url: string,
|
||||
module: string,
|
||||
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
|
||||
if (!url.startsWith("data:")) return Effect.undefined
|
||||
if (!url.startsWith("data:")) return Effect.succeed(undefined)
|
||||
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
|
||||
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
|
||||
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -80,8 +80,6 @@ import type {
|
||||
SessionMessageOutput,
|
||||
SessionEnvironmentInput,
|
||||
SessionEnvironmentOutput,
|
||||
SessionViewInput,
|
||||
SessionViewOutput,
|
||||
MessageListInput,
|
||||
MessageListOutput,
|
||||
ModelListInput,
|
||||
@@ -900,18 +898,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
view: (input: SessionViewInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionViewOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/view`,
|
||||
body: { idle: input["idle"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
message: {
|
||||
list: (input: MessageListInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -263,8 +263,6 @@ export type SessionInboxCompaction = {
|
||||
|
||||
export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue }
|
||||
|
||||
export type InstructionEntrySnapshot = Array<{ key: InstructionEntryKey; value: JsonValue; removed: boolean }>
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -326,16 +324,6 @@ export type SessionRenamed = {
|
||||
data: { sessionID: string; title: string }
|
||||
}
|
||||
|
||||
export type SessionViewed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.viewed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; idle: number }
|
||||
}
|
||||
|
||||
export type SessionDeleted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -346,6 +334,16 @@ export type SessionDeleted = {
|
||||
data: { sessionID: string }
|
||||
}
|
||||
|
||||
export type SessionForked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.forked"
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; parentID: string; boundary: SessionForkBoundary; instructions?: { [x: string]: string } }
|
||||
}
|
||||
|
||||
export type SessionInboxDelivered = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1089,7 +1087,7 @@ export type PtyUpdated = {
|
||||
data: { info: Pty }
|
||||
}
|
||||
|
||||
export type SessionStatusUpdated = {
|
||||
export type SessionStatus2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
@@ -1304,22 +1302,6 @@ export type FormWhen = {
|
||||
|
||||
export type ToolContent = ToolTextContent | ToolFileContent
|
||||
|
||||
export type SessionForked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.forked"
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
parentID: string
|
||||
boundary: SessionForkBoundary
|
||||
instructions?: { [x: string]: string }
|
||||
instructionEntries?: InstructionEntrySnapshot
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionShellStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1682,8 +1664,7 @@ export type SessionInfo = {
|
||||
model?: ModelRef
|
||||
cost: MoneyUSD
|
||||
tokens: TokenUsageInfo
|
||||
outcome?: "succeeded" | "failed" | "interrupted"
|
||||
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
|
||||
time: { created: number; updated: number; archived?: number }
|
||||
title?: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
@@ -1968,7 +1949,6 @@ export type SessionEventDurable =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionViewed
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
| SessionInboxDelivered
|
||||
@@ -2054,7 +2034,6 @@ export type V2Event =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionViewed
|
||||
| SessionUsageUpdated
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
@@ -2117,7 +2096,7 @@ export type V2Event =
|
||||
| FormReplied
|
||||
| FormCancelled
|
||||
| WebsearchUpdated
|
||||
| SessionStatusUpdated
|
||||
| SessionStatus2
|
||||
| SessionIdle
|
||||
| TuiPromptAppend
|
||||
| TuiCommandExecute
|
||||
@@ -2531,14 +2510,7 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly outcome?: "succeeded" | "failed" | "interrupted"
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly updated: number
|
||||
readonly idle?: number
|
||||
readonly viewed?: number
|
||||
readonly archived?: number
|
||||
}
|
||||
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
|
||||
readonly title?: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
@@ -2806,14 +2778,7 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly outcome?: "succeeded" | "failed" | "interrupted"
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly updated: number
|
||||
readonly idle?: number
|
||||
readonly viewed?: number
|
||||
readonly archived?: number
|
||||
}
|
||||
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
|
||||
readonly title?: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
@@ -3081,14 +3046,7 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly outcome?: "succeeded" | "failed" | "interrupted"
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly updated: number
|
||||
readonly idle?: number
|
||||
readonly viewed?: number
|
||||
readonly archived?: number
|
||||
}
|
||||
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
|
||||
readonly title?: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
@@ -4022,13 +3980,6 @@ export type SessionEnvironmentInput = {
|
||||
|
||||
export type SessionEnvironmentOutput = void
|
||||
|
||||
export type SessionViewInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly idle: { readonly idle: number }["idle"]
|
||||
}
|
||||
|
||||
export type SessionViewOutput = void
|
||||
|
||||
export type MessageListInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly limit?: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Client data layer: apply server events and cache API reads into a Solid store.
|
||||
// Prefer straightforward projection. Invalidated reads revalidate serially so an older
|
||||
// response cannot commit after its replacement. Reconnect invalidates cached reads;
|
||||
// active UI owners decide what to sync again.
|
||||
// Prefer straightforward projection. Do not add generation counters, stale-response
|
||||
// merges, live/history overlays, or other race machinery here—last write wins.
|
||||
// Reconnect invalidates cached reads; active UI owners decide what to sync again.
|
||||
|
||||
import type {
|
||||
AgentInfo,
|
||||
@@ -120,46 +120,32 @@ function locationQuery(ref?: LocationRef) {
|
||||
}
|
||||
|
||||
function createSync() {
|
||||
type Pending = { promise: Promise<void>; invalidated: boolean }
|
||||
const state = new Map<string, true | Pending>()
|
||||
const start = (key: string, load: () => Promise<void>, wait?: Promise<void>) => {
|
||||
const entry: Pending = { promise: Promise.resolve(), invalidated: false }
|
||||
state.set(key, entry)
|
||||
entry.promise = (wait ? wait.catch(() => undefined).then(load) : load())
|
||||
.then(() => {
|
||||
if (state.get(key) === entry) state.set(key, true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (state.get(key) === entry) state.delete(key)
|
||||
})
|
||||
return entry.promise
|
||||
}
|
||||
const state = new Map<string, true | Promise<void>>()
|
||||
return {
|
||||
run(key: string, load: () => Promise<void>) {
|
||||
const active = state.get(key)
|
||||
if (active === true) return Promise.resolve()
|
||||
if (!active) return start(key, load)
|
||||
if (!active.invalidated) return active.promise
|
||||
return start(key, load, active.promise)
|
||||
if (active) return active
|
||||
const pending = load()
|
||||
.then(() => {
|
||||
if (state.get(key) === pending) state.set(key, true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (state.get(key) === pending) state.delete(key)
|
||||
})
|
||||
state.set(key, pending)
|
||||
return pending
|
||||
},
|
||||
complete(key: string) {
|
||||
if (state.has(key)) return
|
||||
state.set(key, true)
|
||||
},
|
||||
has(key: string) {
|
||||
return state.has(key)
|
||||
},
|
||||
invalidate(key?: string) {
|
||||
if (key) {
|
||||
const active = state.get(key)
|
||||
if (active === true) state.delete(key)
|
||||
if (active !== undefined && active !== true) active.invalidated = true
|
||||
state.delete(key)
|
||||
return
|
||||
}
|
||||
state.forEach((active, current) => {
|
||||
if (active === true) state.delete(current)
|
||||
if (active !== true) active.invalidated = true
|
||||
})
|
||||
state.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -896,16 +882,6 @@ export function createData(config: CreateDataInput) {
|
||||
const currentAssistant = message.activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.retry = undefined
|
||||
})
|
||||
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
|
||||
// An event can overtake the first read; queue a revalidation when that read is still active.
|
||||
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
void result.session.sync(event.data.sessionID)
|
||||
return
|
||||
case "session.viewed":
|
||||
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
void result.session.sync(event.data.sessionID)
|
||||
return
|
||||
case "session.revert.staged":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
|
||||
@@ -19,8 +19,8 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
test("generated Effect API names canonical and composed outputs", async () => {
|
||||
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
|
||||
|
||||
expect(source).toContain("export type SessionGetOutput = Session.Info")
|
||||
expect(source).toContain("export type EventSubscribeOutput = OpenCodeEvent")
|
||||
expect(source).toContain("export type Endpoint5_5Output = Session.Info")
|
||||
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
|
||||
expect(source).not.toContain("HttpApiClient.ForApi")
|
||||
})
|
||||
|
||||
|
||||
@@ -136,10 +136,8 @@ test("event.subscribe terminates on Effect protocol decode failures", async () =
|
||||
|
||||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const logQueries: Array<Record<string, string>> = []
|
||||
const requests: Array<{ method: string; url: string }> = []
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
const url = request.url
|
||||
requests.push({ method: request.method, url })
|
||||
if (url.includes("/log")) {
|
||||
logQueries.push(Object.fromEntries(request.urlParams.params))
|
||||
return Effect.succeed(
|
||||
@@ -185,7 +183,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const created = yield* client.session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
|
||||
})
|
||||
yield* client.session.view({ sessionID: Session.ID.make("ses_test"), idle: session.data.time.idle })
|
||||
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
|
||||
yield* client.session.switchModel({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
@@ -210,11 +207,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
return { page, active, created, admitted, context, log, message }
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
const listed = result.page.data[0]
|
||||
if (!listed?.time.idle || !listed.time.viewed) throw new Error("Expected attention times")
|
||||
expect(DateTime.toEpochMillis(listed.time.created)).toBe(1_717_171_717_000)
|
||||
expect(DateTime.toEpochMillis(listed.time.idle)).toBe(1_717_171_717_002)
|
||||
expect(DateTime.toEpochMillis(listed.time.viewed)).toBe(1_717_171_717_001)
|
||||
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
||||
expect(result.active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
|
||||
@@ -224,7 +217,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||
expect(result.context).toEqual([])
|
||||
expect(logQueries[0]).toEqual({ after: "0" })
|
||||
expect(requests).toContainEqual({ method: "POST", url: "http://localhost:3000/api/session/ses_test/view" })
|
||||
const logged = Array.from(result.log)
|
||||
expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
|
||||
expect(logged[0]?.type === "session.model.selected" && logged[0].created).toBe(1_717_171_717_000)
|
||||
@@ -266,8 +258,6 @@ const session = {
|
||||
time: {
|
||||
created: 1_717_171_717_000,
|
||||
updated: 1_717_171_717_000,
|
||||
idle: 1_717_171_717_002,
|
||||
viewed: 1_717_171_717_001,
|
||||
},
|
||||
title: "Test",
|
||||
location: { directory: "/tmp/project" },
|
||||
|
||||
@@ -524,7 +524,6 @@ test("session methods use the public HTTP contract", async () => {
|
||||
const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
|
||||
const active = await client.session.active()
|
||||
const created = await client.session.create({ location: { directory: "/tmp/project" } })
|
||||
await client.session.view({ sessionID: "ses_test", idle: session.data.time.idle })
|
||||
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||
await client.session.switchModel({
|
||||
sessionID: "ses_test",
|
||||
@@ -551,7 +550,6 @@ test("session methods use the public HTTP contract", async () => {
|
||||
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
|
||||
expect(page.cursor.next).toBe("next")
|
||||
expect(page.data[0].time).toMatchObject({ idle: 1_717_171_717_002, viewed: 1_717_171_717_001 })
|
||||
expect(active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
@@ -564,7 +562,6 @@ test("session methods use the public HTTP contract", async () => {
|
||||
["GET", "http://localhost:3000/api/session?limit=10&order=desc&parentID=null"],
|
||||
["GET", "http://localhost:3000/api/session/active"],
|
||||
["POST", "http://localhost:3000/api/session"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/view"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/agent"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/model"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
|
||||
@@ -577,9 +574,6 @@ test("session methods use the public HTTP contract", async () => {
|
||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt?continue=true"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||
])
|
||||
const viewBody = requests.find((request) => request.url.endsWith("/api/session/ses_test/view"))?.init?.body
|
||||
if (typeof viewBody !== "string") throw new Error("Expected JSON view request body")
|
||||
expect(JSON.parse(viewBody)).toEqual({ idle: session.data.time.idle })
|
||||
const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body
|
||||
if (typeof body !== "string") throw new Error("Expected JSON request body")
|
||||
expect(JSON.parse(body)).toEqual({
|
||||
@@ -642,8 +636,6 @@ const session = {
|
||||
time: {
|
||||
created: 1_717_171_717_000,
|
||||
updated: 1_717_171_717_000,
|
||||
idle: 1_717_171_717_002,
|
||||
viewed: 1_717_171_717_001,
|
||||
},
|
||||
title: "Test",
|
||||
location: { directory: "/tmp/project" },
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
|
||||
|
||||
const session = (viewed: number): SessionInfo => ({
|
||||
id: "ses_refresh",
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
outcome: "succeeded",
|
||||
time: { created: 0, updated: 0, idle: 2, viewed },
|
||||
location: { directory: "/project" },
|
||||
})
|
||||
|
||||
test("revalidates after an event overtakes an active session read", async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => (release = resolve))
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
let requests = 0
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
if (!request.url.endsWith("/api/session/ses_refresh")) throw new Error(`Unexpected request: ${request.url}`)
|
||||
requests++
|
||||
if (requests === 1) {
|
||||
await gate
|
||||
return Response.json({ data: session(1) })
|
||||
}
|
||||
return Response.json({ data: session(2) })
|
||||
},
|
||||
})
|
||||
const event: CreateDataInput["event"] = {
|
||||
on:
|
||||
<Type extends OpenCodeEvent["type"]>(
|
||||
_type: Type,
|
||||
_handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
|
||||
) =>
|
||||
() => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
}
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({ api: () => api, directory: "/project", event, connection: { status: () => "connected" } }),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
try {
|
||||
setup.data.session.remember(session(1))
|
||||
setup.data.session.invalidate("ses_refresh")
|
||||
const initial = setup.data.session.sync("ses_refresh")
|
||||
await wait(() => requests === 1)
|
||||
|
||||
const viewed: OpenCodeEvent = {
|
||||
id: "evt_viewed",
|
||||
created: 2,
|
||||
type: "session.viewed",
|
||||
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
|
||||
data: { sessionID: "ses_refresh", idle: 2 },
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: viewed.type, details: viewed }))
|
||||
await Bun.sleep(20)
|
||||
release()
|
||||
await initial
|
||||
|
||||
await wait(() => requests === 2 && setup.data.session.get("ses_refresh")?.time.viewed === 2)
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
async function wait(check: () => boolean) {
|
||||
const started = Date.now()
|
||||
while (!check()) {
|
||||
if (Date.now() - started > 2_000) throw new Error("Timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
@@ -841,17 +841,17 @@ export class Interpreter<R> {
|
||||
|
||||
private customIterator(value: unknown, node: AstNode, allowAsync = true) {
|
||||
if (value instanceof CodeModeGenerator) {
|
||||
if (value.asynchronous && !allowAsync) return Effect.undefined
|
||||
if (value.asynchronous && !allowAsync) return Effect.succeed(undefined)
|
||||
return Effect.succeed({
|
||||
iterator: value,
|
||||
next: new GeneratorMethodReference(value, "next"),
|
||||
asynchronous: value.asynchronous,
|
||||
})
|
||||
}
|
||||
if (!isRecord(value) || isRuntimeReference(value)) return Effect.undefined
|
||||
if (!isRecord(value) || isRuntimeReference(value)) return Effect.succeed(undefined)
|
||||
const asyncMethod = allowAsync ? Reflect.get(value, AsyncIteratorSymbol) : undefined
|
||||
const method = asyncMethod ?? Reflect.get(value, IteratorSymbol)
|
||||
if (method === undefined || method === null) return Effect.undefined
|
||||
if (method === undefined || method === null) return Effect.succeed(undefined)
|
||||
const self = this
|
||||
return Effect.map(
|
||||
this.invokeCallable(this.requireIteratorMethod(method, "Iterator method", node), [], node),
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/shell-scan": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "3fb67508-0196-4bae-b2bd-c08ece7583fd",
|
||||
"prevIds": ["dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"],
|
||||
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
|
||||
"prevIds": ["5c1aa56b-c3ee-4283-9a84-c0bf626dc604"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -1350,36 +1350,6 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_idle",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_viewed",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "idle_outcome",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
|
||||
@@ -46,7 +46,7 @@ export const Plugin = define({
|
||||
|
||||
function firstMissing(target: string): Effect.Effect<string | undefined> {
|
||||
const parent = path.dirname(target)
|
||||
if (parent === target) return Effect.undefined
|
||||
if (parent === target) return Effect.succeed(undefined)
|
||||
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
|
||||
}
|
||||
|
||||
|
||||
-2
@@ -43,7 +43,6 @@ import m40 from "./migration/20260808023530_workspace_domain.js"
|
||||
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
|
||||
import m42 from "./migration/20260812181746_session_inbox.js"
|
||||
import m43 from "./migration/20260812213948_worktree.js"
|
||||
import m44 from "./migration/20260819222447_session_viewed_state.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -90,5 +89,4 @@ export const migrations = [
|
||||
m41,
|
||||
m42,
|
||||
m43,
|
||||
m44,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260819222447_session_viewed_state",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_idle\` integer;`)
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_viewed\` integer;`)
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`idle_outcome\` text;`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -209,9 +209,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`model\` text,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`time_idle\` integer,
|
||||
\`time_viewed\` integer,
|
||||
\`idle_outcome\` text,
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
|
||||
@@ -109,7 +109,7 @@ const layer = Layer.effect(
|
||||
const next = Bom.split(input.content)
|
||||
const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe(
|
||||
Effect.map((result) => result.bytes),
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.undefined),
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
|
||||
)
|
||||
yield* environment.files.write(
|
||||
input.target.absolute,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { File } from "./file.js"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
import { gitExecutable } from "./util/git-executable.js"
|
||||
|
||||
export class Repository extends Schema.Class<Repository>("Git.Repository")({
|
||||
worktree: AbsolutePath,
|
||||
@@ -314,7 +315,7 @@ const layer = Layer.effect(
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
ChildProcess.make("git", repositoryArgs(repository, args), {
|
||||
ChildProcess.make(gitExecutable, repositoryArgs(repository, args), {
|
||||
cwd: repository.worktree,
|
||||
env: options?.env,
|
||||
extendEnv: true,
|
||||
@@ -449,10 +450,14 @@ const layer = Layer.effect(
|
||||
if (!input.paths.length) return new Set<RelativePath>()
|
||||
const result = yield* proc
|
||||
.run(
|
||||
ChildProcess.make("git", repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]), {
|
||||
cwd: input.repository.worktree,
|
||||
extendEnv: true,
|
||||
}),
|
||||
ChildProcess.make(
|
||||
gitExecutable,
|
||||
repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]),
|
||||
{
|
||||
cwd: input.repository.worktree,
|
||||
extendEnv: true,
|
||||
},
|
||||
),
|
||||
{ stdin: input.paths.join("\0") + "\0" },
|
||||
)
|
||||
.pipe(
|
||||
@@ -625,7 +630,7 @@ const layer = Layer.effect(
|
||||
cwd = repository.worktree,
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
|
||||
.run(ChildProcess.make(gitExecutable, args, { cwd, extendEnv: true, stdin: "ignore" }))
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
|
||||
@@ -722,7 +727,7 @@ function execute(cwd: string, proc: AppProcess.Interface) {
|
||||
return (args: string[]) =>
|
||||
proc
|
||||
.run(
|
||||
ChildProcess.make("git", args, {
|
||||
ChildProcess.make(gitExecutable, args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
|
||||
@@ -78,8 +78,9 @@ const layer = Layer.effect(
|
||||
? "Directory"
|
||||
: input.kind === "file"
|
||||
? "File"
|
||||
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
|
||||
?.type
|
||||
: (yield* fs
|
||||
.stat(absolute)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))))?.type
|
||||
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
|
||||
const externalResource = slash(path.join(externalDirectory, "*"))
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
@@ -147,7 +146,7 @@ export function buildLocationServiceMap(
|
||||
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||
)
|
||||
},
|
||||
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? "60 minutes" : 0) },
|
||||
{ idleTimeToLive: "60 minutes" },
|
||||
),
|
||||
(inner) => ({
|
||||
...inner,
|
||||
|
||||
@@ -624,11 +624,11 @@ export const layer = (options?: Options) =>
|
||||
Effect.map((input) => input as Record<string, SourceProvider>),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
: Effect.undefined
|
||||
: Effect.succeed(undefined)
|
||||
|
||||
// The bundled snapshot is the boot-time floor for the catalog; the
|
||||
// periodic fetch below still refreshes on top.
|
||||
const loadSnapshot = options?.snapshot === false ? Effect.undefined : bundledSnapshot
|
||||
const loadSnapshot = options?.snapshot === false ? Effect.succeed(undefined) : bundledSnapshot
|
||||
|
||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||
const text = yield* fetchApi()
|
||||
|
||||
@@ -302,7 +302,7 @@ const layer = Layer.effect(
|
||||
const rememberedRules = yield* savedRules()
|
||||
for (const [id, item] of pending) {
|
||||
const rules = yield* configured(item.request.sessionID, item.agent).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.undefined),
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.succeed(undefined)),
|
||||
)
|
||||
if (!rules) continue
|
||||
if (denied(item.request, rules)) continue
|
||||
|
||||
@@ -46,18 +46,15 @@ function oauth(http: HttpClient.HttpClient) {
|
||||
Effect.gen(function* () {
|
||||
const server = yield* normalizeServer(answer.server ?? defaultServer)
|
||||
const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device)
|
||||
const verification = yield* Effect.try({
|
||||
try: () => {
|
||||
const url = new URL(device.verification_uri_complete, `${server}/`)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)")
|
||||
return url
|
||||
},
|
||||
catch: (cause) =>
|
||||
new Error(`Invalid device verification URL: ${cause instanceof Error ? cause.message : String(cause)}`),
|
||||
})
|
||||
const verification = URL.canParse(device.verification_uri_complete)
|
||||
? new URL(device.verification_uri_complete)
|
||||
: undefined
|
||||
if (verification && verification.protocol !== "http:" && verification.protocol !== "https:") {
|
||||
return yield* Effect.fail(new Error("Invalid device verification URL: expected HTTP(S)"))
|
||||
}
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: verification.href,
|
||||
url: verification?.href ?? `${server}/${device.verification_uri_complete.replace(/^\/+/, "")}`,
|
||||
instructions: `Enter code: ${device.user_code}`,
|
||||
callback: poll(http, server, device.device_code, Duration.seconds(device.interval)),
|
||||
}
|
||||
@@ -216,7 +213,7 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
)
|
||||
.pipe(
|
||||
Effect.flatMap((response) => {
|
||||
if (response.status === 404) return Effect.undefined
|
||||
if (response.status === 404) return Effect.succeed(undefined)
|
||||
return HttpClientResponse.filterStatusOk(response).pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
|
||||
Effect.map((remote) => remote.config.provider),
|
||||
|
||||
@@ -10,7 +10,7 @@ export const parseResponse = <F extends Schema.Struct.Fields>(body: string, resu
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ result })))
|
||||
const parse = (payload: string) => {
|
||||
const trimmed = payload.trim()
|
||||
if (!trimmed.startsWith("{")) return Effect.undefined
|
||||
if (!trimmed.startsWith("{")) return Effect.succeed(undefined)
|
||||
return decode(trimmed).pipe(Effect.map((response) => response.result))
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
|
||||
@@ -235,7 +235,7 @@ const layer = Layer.effect(
|
||||
Effect.mapError((cause) => failure("Invalid ripgrep JSON output", cause)),
|
||||
Effect.flatMap((json) => {
|
||||
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
|
||||
return Effect.undefined
|
||||
return Effect.succeed(undefined)
|
||||
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
|
||||
Effect.map((match) => ({
|
||||
...match.data,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Session from "./session.js"
|
||||
export * from "./session/schema.js"
|
||||
|
||||
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
|
||||
import { Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
@@ -54,7 +54,6 @@ import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
import { fileURLToPath } from "url"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { SessionHistory } from "./session/history.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
|
||||
// get project -> project.locations
|
||||
//
|
||||
@@ -157,11 +156,6 @@ export class DestinationNotDirectoryError extends Schema.TaggedError<Destination
|
||||
"Session.DestinationNotDirectoryError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class DestinationUnavailableError extends Schema.TaggedError<DestinationUnavailableError>()(
|
||||
"Session.DestinationUnavailableError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
|
||||
@@ -178,7 +172,6 @@ export interface Interface {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly variables?: SessionEnvironment.Variables
|
||||
}) => Effect.Effect<SessionEnvironment.Variables | undefined, NotFoundError>
|
||||
readonly view: (input: { sessionID: SessionSchema.ID; idle: number }) => Effect.Effect<void, NotFoundError>
|
||||
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly messages: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -225,10 +218,7 @@ export interface Interface {
|
||||
directory: AbsolutePath
|
||||
workspaceID?: Location.Ref["workspaceID"]
|
||||
delivery?: SessionInbox.Delivery
|
||||
}) => Effect.Effect<
|
||||
void,
|
||||
NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError
|
||||
>
|
||||
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
|
||||
readonly prompt: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -438,14 +428,6 @@ const layer = Layer.effect(
|
||||
})
|
||||
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
|
||||
const sessionID = SessionSchema.ID.create()
|
||||
const inherited = yield* db
|
||||
.transaction(() =>
|
||||
Effect.all({
|
||||
instructions: InstructionState.current(db, parent.id),
|
||||
instructionEntries: InstructionEntry.snapshot(db, parent.id),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
// The fork adopts the parent's newest instruction values rather than the
|
||||
// values in effect at the boundary; copied history may contain frozen
|
||||
// instruction-update text the initial baseline already reflects.
|
||||
@@ -453,7 +435,7 @@ const layer = Layer.effect(
|
||||
sessionID,
|
||||
parentID: parent.id,
|
||||
boundary: { ...input.boundary, messageID: boundary.id },
|
||||
...inherited,
|
||||
instructions: yield* InstructionState.current(db, parent.id),
|
||||
})
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
}),
|
||||
@@ -467,18 +449,6 @@ const layer = Layer.effect(
|
||||
if (input.variables !== undefined) yield* environments.set(input.sessionID, input.variables)
|
||||
return yield* environments.get(input.sessionID)
|
||||
}),
|
||||
view: Effect.fn("Session.view")(function* (input) {
|
||||
const row = yield* db
|
||||
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
|
||||
if (row.idle === null || input.idle > row.idle || (row.viewed !== null && row.viewed >= input.idle))
|
||||
return yield* Effect.void
|
||||
yield* bus.publish(SessionEvent.Viewed, { sessionID: input.sessionID, idle: input.idle })
|
||||
}),
|
||||
remove: Effect.fn("Session.remove")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
yield* execution.interrupt(sessionID)
|
||||
@@ -802,22 +772,12 @@ const layer = Layer.effect(
|
||||
if (!info) return yield* new DestinationNotFoundError({ directory })
|
||||
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||
const project = yield* projects.resolve(directory)
|
||||
yield* persistProject(project)
|
||||
const payload: SessionInbox.MovePayload = {
|
||||
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
|
||||
projectID: project.id,
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
}
|
||||
yield* Location.Service.pipe(
|
||||
Effect.provide(locations.get(payload.location)),
|
||||
Effect.scoped,
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
return Effect.logWarning("session move destination unavailable", { directory, cause }).pipe(
|
||||
Effect.andThen(Effect.fail(new DestinationUnavailableError({ directory }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* persistProject(project)
|
||||
const item = SessionInbox.Item.make({
|
||||
type: "move",
|
||||
payload,
|
||||
|
||||
@@ -50,12 +50,9 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
||||
}),
|
||||
subpath: row.path ? RelativePath.make(row.path) : undefined,
|
||||
revert: row.revert ? decodeRevert(row.revert) : undefined,
|
||||
outcome: row.idle_outcome ?? undefined,
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(row.time_created),
|
||||
updated: DateTime.makeUnsafe(row.time_updated),
|
||||
idle: row.time_idle === null ? undefined : DateTime.makeUnsafe(row.time_idle),
|
||||
viewed: row.time_viewed === null ? undefined : DateTime.makeUnsafe(row.time_viewed),
|
||||
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -13,59 +13,9 @@ export const Key = InstructionEntry.Key
|
||||
export type Key = typeof Key.Type
|
||||
export const Info = InstructionEntry.Info
|
||||
export type Info = typeof Info.Type
|
||||
export const Snapshot = InstructionEntry.Snapshot
|
||||
export type Snapshot = typeof Snapshot.Type
|
||||
export const MaxValueBytes = InstructionEntry.MaxValueBytes
|
||||
export const ValueTooLargeError = InstructionEntry.ValueTooLargeError
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
const InsertBatchSize = 10
|
||||
|
||||
export const snapshot = Effect.fn("InstructionEntry.snapshot")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
return yield* db
|
||||
.select({
|
||||
key: InstructionEntryTable.key,
|
||||
value: InstructionEntryTable.value,
|
||||
removed: InstructionEntryTable.removed,
|
||||
})
|
||||
.from(InstructionEntryTable)
|
||||
.where(eq(InstructionEntryTable.session_id, sessionID))
|
||||
.orderBy(asc(InstructionEntryTable.key))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const initialize = Effect.fn("InstructionEntry.initialize")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
entries: Snapshot,
|
||||
created: number,
|
||||
) {
|
||||
const batches = Array.from({ length: Math.ceil(entries.length / InsertBatchSize) }, (_, index) =>
|
||||
entries.slice(index * InsertBatchSize, (index + 1) * InsertBatchSize),
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
batches,
|
||||
(batch) =>
|
||||
db
|
||||
.insert(InstructionEntryTable)
|
||||
.values(
|
||||
batch.map((entry) => ({
|
||||
...entry,
|
||||
session_id: sessionID,
|
||||
time_created: created,
|
||||
time_updated: created,
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Info>>
|
||||
readonly put: (input: {
|
||||
|
||||
@@ -70,7 +70,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
Match.type<SessionEvent.DurableEvent>(),
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
"session.created": () => Effect.void,
|
||||
"session.viewed": () => Effect.void,
|
||||
"session.usage.recorded": () => Effect.void,
|
||||
"session.agent.selected": (event) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { SessionInbox } from "./inbox.js"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { InstructionEntry } from "./instruction-entry.js"
|
||||
import { Slug } from "../util/slug.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
@@ -172,9 +171,6 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
.pipe(Effect.orDie)
|
||||
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
|
||||
|
||||
if (event.data.instructionEntries)
|
||||
yield* InstructionEntry.initialize(db, event.data.sessionID, event.data.instructionEntries, event.created)
|
||||
|
||||
let cursor = -1
|
||||
while (copiedSeq !== undefined) {
|
||||
const rows = yield* db
|
||||
@@ -187,7 +183,6 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
lt(SessionMessageTable.seq, copiedSeq + 1),
|
||||
// Terminal events for active projections stay on the parent, so forks copy only settled history.
|
||||
sql`${SessionMessageTable.type} != 'assistant' or json_extract(${SessionMessageTable.data}, '$.time.completed') is not null`,
|
||||
sql`${SessionMessageTable.type} != 'shell' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
|
||||
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
|
||||
),
|
||||
)
|
||||
@@ -201,7 +196,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
.insert(SessionMessageTable)
|
||||
.values(
|
||||
rows.map((row) => ({
|
||||
id: SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${row.seq}`),
|
||||
id: SessionMessage.ID.create(),
|
||||
session_id: event.data.sessionID,
|
||||
type: row.type,
|
||||
seq: row.seq,
|
||||
@@ -395,37 +390,6 @@ function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, me
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
function projectIdle(
|
||||
db: DatabaseService,
|
||||
event:
|
||||
| typeof SessionEvent.Execution.Succeeded.Type
|
||||
| typeof SessionEvent.Execution.Failed.Type
|
||||
| typeof SessionEvent.Execution.Interrupted.Type,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
if (event.type === SessionEvent.Execution.Interrupted.type && event.data.reason === "shutdown") return
|
||||
const time = event.created
|
||||
const outcome =
|
||||
event.type === SessionEvent.Execution.Succeeded.type
|
||||
? "succeeded"
|
||||
: event.type === SessionEvent.Execution.Failed.type
|
||||
? "failed"
|
||||
: "interrupted"
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
// Unread uses a strict timestamp comparison, so every terminal must advance even within one millisecond.
|
||||
time_idle: sql`max(${time}, coalesce(${SessionTable.time_idle} + 1, ${time}))`,
|
||||
idle_outcome: outcome,
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
}
|
||||
|
||||
const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
@@ -547,20 +511,6 @@ const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Viewed, (event) => {
|
||||
const idle = event.data.idle
|
||||
return db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
// Monotone watermark: a duplicate or stale view never regresses, and a terminal event
|
||||
// committing after the viewer's observation keeps the newer idle transition unread.
|
||||
time_viewed: sql`max(${idle}, coalesce(${SessionTable.time_viewed}, ${idle}))`,
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
|
||||
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
|
||||
yield* bus.project(SessionEvent.InboxDelivered, (event) =>
|
||||
@@ -625,9 +575,9 @@ const layer = Layer.effectDiscard(
|
||||
delivery: event.data.delivery,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => projectIdle(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Failed, (event) => projectIdle(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => projectIdle(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
|
||||
@@ -57,9 +57,6 @@ export const SessionTable = sqliteTable(
|
||||
variant?: string
|
||||
}>(),
|
||||
...Timestamps,
|
||||
time_idle: integer(),
|
||||
time_viewed: integer(),
|
||||
idle_outcome: text().$type<NonNullable<Session.Info["outcome"]>>(),
|
||||
time_compacting: integer(),
|
||||
time_archived: integer(),
|
||||
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
|
||||
|
||||
@@ -53,7 +53,7 @@ const layer = Layer.effect(
|
||||
export: Effect.fn("SessionTransfer.export")(function* (input) {
|
||||
const data = {
|
||||
info: yield* sessions.get(input.sessionID),
|
||||
messages: (yield* sessions.messages({ sessionID: input.sessionID, order: "asc" })).filter(isSettled),
|
||||
messages: yield* sessions.messages({ sessionID: input.sessionID, order: "asc" }),
|
||||
}
|
||||
return input.sanitize ? sanitize(data) : data
|
||||
}),
|
||||
@@ -68,7 +68,7 @@ const layer = Layer.effect(
|
||||
if (recorded) return yield* new ImportConflictError({ sessionID })
|
||||
const project = yield* projects.resolve(input.location.directory)
|
||||
yield* upsertProject(db, project).pipe(Effect.orDie)
|
||||
const messages = input.data.messages.filter(isSettled).map((message, index) => {
|
||||
const messages = input.data.messages.map((message, index) => {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id: _, type, ...data } = encoded
|
||||
return {
|
||||
@@ -115,15 +115,6 @@ const layer = Layer.effect(
|
||||
tokens_cache_write: input.data.info.tokens.cache.write,
|
||||
time_created: DateTime.toEpochMillis(input.data.info.time.created),
|
||||
time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
|
||||
time_idle: input.data.info.time.idle ? DateTime.toEpochMillis(input.data.info.time.idle) : null,
|
||||
time_viewed:
|
||||
input.data.info.time.idle && input.data.info.time.viewed
|
||||
? Math.min(
|
||||
DateTime.toEpochMillis(input.data.info.time.idle),
|
||||
DateTime.toEpochMillis(input.data.info.time.viewed),
|
||||
)
|
||||
: null,
|
||||
idle_outcome: input.data.info.time.idle ? (input.data.info.outcome ?? null) : null,
|
||||
time_archived: input.data.info.time.archived
|
||||
? DateTime.toEpochMillis(input.data.info.time.archived)
|
||||
: null,
|
||||
@@ -153,12 +144,6 @@ export const node = makeGlobalNode({
|
||||
deps: [App.node, Bus.node, Database.node, Project.node, Session.node],
|
||||
})
|
||||
|
||||
function isSettled(message: SessionMessage.Info) {
|
||||
if (message.type === "assistant") return message.time.completed !== undefined
|
||||
if (message.type === "shell" || message.type === "compaction") return message.status !== "running"
|
||||
return true
|
||||
}
|
||||
|
||||
function redact(kind: string, id: string, value: string) {
|
||||
return value.trim() ? `[redacted:${kind}:${id}]` : value
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ const scanLegacy = Effect.fnUntraced(function* (command: string, shell: string,
|
||||
})
|
||||
|
||||
async function scanPortable(command: string, shell: string, cwd: string) {
|
||||
const { ShellScan } = await import("./scan.js")
|
||||
const { ShellScan } = await import("@opencode-ai/shell-scan")
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
|
||||
if (result.kind === "opaque") return { commands: [{ resource: command, save: command }], directories: [] }
|
||||
|
||||
@@ -201,7 +201,7 @@ export const noopLayer = Layer.succeed(
|
||||
Service.of({
|
||||
transform: () => Effect.succeed({ dispose: Effect.void }),
|
||||
reload: () => Effect.void,
|
||||
capture: () => Effect.undefined,
|
||||
capture: () => Effect.succeed(undefined),
|
||||
files: () => Effect.succeed([]),
|
||||
diff: () => Effect.succeed([]),
|
||||
restore: () => Effect.void,
|
||||
|
||||
@@ -78,7 +78,7 @@ export const Plugin = {
|
||||
source,
|
||||
})
|
||||
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.undefined),
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
|
||||
)
|
||||
const next = Bom.split(input.content)
|
||||
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import path from "path"
|
||||
import { which } from "./which.js"
|
||||
|
||||
const resolved = process.platform === "win32" ? which("git") : undefined
|
||||
|
||||
export const gitExecutable = resolved ? path.resolve(resolved) : "git"
|
||||
@@ -8,6 +8,7 @@ import { AppProcess } from "@opencode-ai/util/process"
|
||||
import type { DiffOptions, Interface } from "../vcs.js"
|
||||
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch.js"
|
||||
import type { Patch } from "./patch.js"
|
||||
import { gitExecutable } from "../util/git-executable.js"
|
||||
|
||||
/**
|
||||
* Git adapter for the Vcs service. Ported from the V1 pipeline: patches are
|
||||
@@ -128,7 +129,7 @@ function makeGit(proc: AppProcess.Interface) {
|
||||
const run = Effect.fnUntraced(
|
||||
function* (args: string[], opts: { cwd: string; maxOutputBytes?: number }) {
|
||||
const result = yield* proc.run(
|
||||
ChildProcess.make("git", [...cfg, ...args], {
|
||||
ChildProcess.make(gitExecutable, [...cfg, ...args], {
|
||||
cwd: opts.cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
|
||||
@@ -31,7 +31,7 @@ export const make = Effect.gen(function* () {
|
||||
return yield* Effect.forEach(entries, (entry) =>
|
||||
canonical(fs, entry.directory).pipe(
|
||||
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "worktree" }) as const),
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.undefined),
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
|
||||
}),
|
||||
|
||||
@@ -17,7 +17,6 @@ import previousV2Migration from "@opencode-ai/core/database/migration/2026080423
|
||||
import workspaceMigration from "@opencode-ai/core/database/migration/20260808023530_workspace_domain"
|
||||
import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts"
|
||||
import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox"
|
||||
import sessionViewedStateMigration from "@opencode-ai/core/database/migration/20260819222447_session_viewed_state"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
const run = <A, E>(
|
||||
@@ -78,28 +77,6 @@ describe("DatabaseMigration", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("adds nullable attention state to existing sessions", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session_v2 (id text PRIMARY KEY, title text)`)
|
||||
yield* db.run(sql`INSERT INTO session_v2 (id, title) VALUES ('ses_existing', 'Existing')`)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
|
||||
yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
|
||||
|
||||
expect(yield* db.get(sql`SELECT id, title, time_idle, time_viewed, idle_outcome FROM session_v2`)).toEqual({
|
||||
id: "ses_existing",
|
||||
title: "Existing",
|
||||
time_idle: null,
|
||||
time_viewed: null,
|
||||
idle_outcome: null,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT count(*) AS count FROM migration`)).toEqual({ count: 1 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects a non-empty database without a session table", async () => {
|
||||
await expect(
|
||||
run(
|
||||
|
||||
@@ -10,7 +10,7 @@ export const emptyCredentialNode = makeGlobalNode({
|
||||
Credential.Service.of({
|
||||
all: () => Effect.succeed([]),
|
||||
list: () => Effect.succeed([]),
|
||||
get: () => Effect.undefined,
|
||||
get: () => Effect.succeed(undefined),
|
||||
create: () => Effect.die("unused Credential.create"),
|
||||
update: () => Effect.die("unused Credential.update"),
|
||||
remove: () => Effect.die("unused Credential.remove"),
|
||||
|
||||
@@ -19,9 +19,9 @@ export const emptyMcpLayer = Layer.succeed(
|
||||
callTool: () => Effect.die("unused mcp.callTool"),
|
||||
instructions: () => Effect.succeed([]),
|
||||
prompts: () => Effect.succeed([]),
|
||||
prompt: () => Effect.undefined,
|
||||
prompt: () => Effect.succeed(undefined),
|
||||
resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })),
|
||||
readResource: () => Effect.undefined,
|
||||
readResource: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const runtime = LanguageModel.make({ id: "gemini", provider: "test-provider", ro
|
||||
|
||||
const catalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.undefined,
|
||||
get: () => Effect.succeed(undefined),
|
||||
all: () => Effect.die("unused"),
|
||||
available: () => Effect.die("unused"),
|
||||
},
|
||||
@@ -35,7 +35,7 @@ const catalog = Layer.mock(Catalog.Service, {
|
||||
})
|
||||
const integrations = Layer.mock(Integration.Service, {
|
||||
connection: {
|
||||
active: () => Effect.undefined,
|
||||
active: () => Effect.succeed(undefined),
|
||||
resolve: () => Effect.die("unused"),
|
||||
key: () => Effect.die("unused"),
|
||||
update: () => Effect.die("unused"),
|
||||
|
||||
@@ -405,7 +405,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
FSUtil.Service.of({
|
||||
...fs,
|
||||
up: () => Effect.succeed([discovered]),
|
||||
readFileStringSafe: () => Effect.undefined,
|
||||
readFileStringSafe: () => Effect.succeed(undefined),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -17,7 +17,7 @@ const failingCredentialNode = makeGlobalNode({
|
||||
Credential.Service.of({
|
||||
all: () => Effect.succeed([]),
|
||||
list: () => Effect.succeed([]),
|
||||
get: () => Effect.undefined,
|
||||
get: () => Effect.succeed(undefined),
|
||||
create: () => Effect.die(new Error("credential persistence failed")),
|
||||
update: () => Effect.void,
|
||||
remove: () => Effect.void,
|
||||
|
||||
@@ -43,28 +43,6 @@ const itWithSdk = testEffect(
|
||||
)
|
||||
|
||||
describe("LocationServiceMap", () => {
|
||||
it.live("retries a location after its missing directory is recreated", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const directory = path.join(dir.path, "recreated")
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
|
||||
const first = yield* Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped, Effect.exit)
|
||||
expect(first._tag).toBe("Failure")
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
const location = yield* Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped)
|
||||
expect(location.directory).toBe(ref.directory)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("preserves embedded SDK plugins after Location eviction", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -309,7 +309,7 @@ describe("ModelResolver", () => {
|
||||
})
|
||||
const integrations = Layer.mock(Integration.Service, {
|
||||
connection: {
|
||||
active: () => Effect.undefined,
|
||||
active: () => Effect.succeed(undefined),
|
||||
resolve: () => Effect.die("unused"),
|
||||
key: () => Effect.die("unused"),
|
||||
update: () => Effect.die("unused"),
|
||||
|
||||
@@ -33,7 +33,7 @@ const npmLayer = Layer.succeed(
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
which: () => Effect.undefined,
|
||||
which: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ function npmEntrypoint(entrypoint?: string) {
|
||||
return Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
which: () => Effect.undefined,
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("OpencodePlugin", () => {
|
||||
return Response.json({
|
||||
device_code: "device",
|
||||
user_code: "user",
|
||||
verification_uri_complete: "/console/device?user_code=user&client_id=opencode-cli",
|
||||
verification_uri_complete: `${url.origin}/verify`,
|
||||
expires_in: 60,
|
||||
interval: 0,
|
||||
})
|
||||
@@ -130,7 +130,7 @@ describe("OpencodePlugin", () => {
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
answer: { server: `${server.url.origin}/console///?ignored=true#ignored` },
|
||||
})
|
||||
expect(attempt.url).toBe(`${server.url.origin}/console/device?user_code=user&client_id=opencode-cli`)
|
||||
expect(attempt.url).toBe(`${server.url.origin}/verify`)
|
||||
yield* eventually(
|
||||
integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }),
|
||||
(status) => status.status === "complete",
|
||||
@@ -148,38 +148,6 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects malformed device verification URLs", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: () =>
|
||||
Response.json({
|
||||
device_code: "device",
|
||||
user_code: "user",
|
||||
verification_uri_complete: "http://[::1",
|
||||
expires_in: 60,
|
||||
interval: 0,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const error = yield* (yield* Integration.Service).oauth
|
||||
.connect({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
answer: { server: server.url.origin },
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Integration.AuthorizationError)
|
||||
expect(String(error.cause)).toContain("Invalid device verification URL")
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects non-HTTP OpenCode servers", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -15,7 +15,7 @@ const it = testEffect(PluginTestLayer)
|
||||
const npm = Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
which: () => Effect.undefined,
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
|
||||
@@ -4,7 +4,6 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -13,7 +12,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Instructions } from "@opencode-ai/core/instructions/index"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -25,7 +23,6 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
@@ -44,7 +41,6 @@ const it = testEffect(
|
||||
SessionStore.node,
|
||||
Session.node,
|
||||
SessionTransfer.node,
|
||||
InstructionEntry.node,
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
@@ -405,95 +401,6 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays a fork with stable projected identities", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const parent = yield* session.create({ location, title: "Parent" })
|
||||
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, parent.id, "steer")
|
||||
yield* session.synthetic({ sessionID: parent.id, text: "Second", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, parent.id, "steer")
|
||||
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
|
||||
const original = (yield* session.context(forked.id)).map((message) => message.id)
|
||||
const recorded = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, forked.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!recorded) return yield* Effect.die(new Error("Fork event not found"))
|
||||
|
||||
yield* bus.remove(forked.id)
|
||||
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run().pipe(Effect.orDie)
|
||||
yield* bus.replay({
|
||||
id: recorded.id,
|
||||
created: recorded.created,
|
||||
aggregateID: recorded.aggregate_id,
|
||||
seq: recorded.seq,
|
||||
type: recorded.type,
|
||||
data: recorded.data,
|
||||
})
|
||||
|
||||
expect((yield* session.context(forked.id)).map((message) => message.id)).toEqual(original)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("inherits instruction entries when forking", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const entries = yield* InstructionEntry.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const parent = yield* session.create({ location })
|
||||
yield* session.prompt({ sessionID: parent.id, text: "Fork context", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, parent.id, "steer")
|
||||
yield* entries.put({ sessionID: parent.id, key: "deploy-target", value: "production" })
|
||||
yield* entries.put({ sessionID: parent.id, key: "retired", value: true })
|
||||
yield* entries.remove({ sessionID: parent.id, key: "retired" })
|
||||
yield* Effect.forEach(
|
||||
Array.from({ length: 20 }, (_, index) => index),
|
||||
(index) => entries.put({ sessionID: parent.id, key: `entry-${String(index).padStart(2, "0")}`, value: index }),
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
|
||||
const inheritedList = yield* entries.list(forked.id)
|
||||
const inheritedValues = yield* entries.load(forked.id).pipe(Effect.flatMap(Instructions.read))
|
||||
|
||||
expect(inheritedList).toHaveLength(21)
|
||||
expect(inheritedList).toContainEqual({ key: "deploy-target", value: "production" })
|
||||
expect(inheritedValues).toContainEqual({
|
||||
key: Instructions.Key.make("api/retired"),
|
||||
value: Instructions.removed,
|
||||
})
|
||||
|
||||
const recorded = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, forked.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!recorded) return yield* Effect.die(new Error("Fork event not found"))
|
||||
yield* entries.put({ sessionID: parent.id, key: "deploy-target", value: "staging" })
|
||||
yield* entries.put({ sessionID: parent.id, key: "new-parent-entry", value: true })
|
||||
yield* bus.remove(forked.id)
|
||||
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run().pipe(Effect.orDie)
|
||||
yield* bus.replay({
|
||||
id: recorded.id,
|
||||
created: recorded.created,
|
||||
aggregateID: recorded.aggregate_id,
|
||||
seq: recorded.seq,
|
||||
type: recorded.type,
|
||||
data: recorded.data,
|
||||
})
|
||||
|
||||
expect(yield* entries.list(forked.id)).toEqual(inheritedList)
|
||||
expect(yield* entries.load(forked.id).pipe(Effect.flatMap(Instructions.read))).toEqual(inheritedValues)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not copy a running assistant into a fork", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -544,49 +451,6 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("copies only settled shell messages into forks", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const parent = yield* session.create({ location })
|
||||
yield* session.prompt({ sessionID: parent.id, text: "Run a shell", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, parent.id, "steer")
|
||||
const shell = Shell.Info.make({
|
||||
id: Shell.ID.make("sh_fork_running"),
|
||||
status: "running",
|
||||
command: "sleep 10",
|
||||
cwd: location.directory,
|
||||
shell: "/bin/sh",
|
||||
file: "/tmp/sh_fork_running.out",
|
||||
metadata: {},
|
||||
time: { started: 0 },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Shell.Started, { sessionID: parent.id, shell })
|
||||
|
||||
const running = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
|
||||
|
||||
expect(yield* session.context(parent.id)).toMatchObject([
|
||||
{ type: "user", text: "Run a shell" },
|
||||
{ type: "shell", command: "sleep 10", status: "running" },
|
||||
])
|
||||
expect(yield* session.context(running.id)).toMatchObject([{ type: "user", text: "Run a shell" }])
|
||||
|
||||
yield* bus.publish(SessionEvent.Shell.Ended, {
|
||||
sessionID: parent.id,
|
||||
shell: { ...shell, status: "exited", exit: 0, time: { started: 0, completed: 1 } },
|
||||
output: { output: "complete", cursor: 8, size: 8, truncated: false },
|
||||
})
|
||||
const completed = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
|
||||
|
||||
expect(yield* session.context(running.id)).toMatchObject([{ type: "user", text: "Run a shell" }])
|
||||
expect(yield* session.context(completed.id)).toMatchObject([
|
||||
{ type: "user", text: "Run a shell" },
|
||||
{ type: "shell", command: "sleep 10", status: "exited", output: { output: "complete" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects forking an empty session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -1023,134 +887,6 @@ describe("Session.create", () => {
|
||||
})
|
||||
|
||||
describe("SessionTransfer", () => {
|
||||
it.effect("exports only settled projected messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const transfer = yield* SessionTransfer.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const source = yield* session.create({ location })
|
||||
yield* session.prompt({ sessionID: source.id, text: "Settled", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, source.id, "steer")
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: source.id,
|
||||
assistantMessageID: SessionMessage.ID.create(),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }),
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Shell.Started, {
|
||||
sessionID: source.id,
|
||||
shell: Shell.Info.make({
|
||||
id: Shell.ID.make("sh_transfer_export"),
|
||||
status: "running",
|
||||
command: "sleep 10",
|
||||
cwd: location.directory,
|
||||
shell: "/bin/sh",
|
||||
file: "/tmp/sh_transfer_export.out",
|
||||
metadata: {},
|
||||
time: { started: 0 },
|
||||
}),
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: source.id,
|
||||
reason: "manual",
|
||||
recent: "pending",
|
||||
})
|
||||
|
||||
expect((yield* transfer.export({ sessionID: source.id })).messages).toMatchObject([
|
||||
{ type: "user", text: "Settled" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("imports only settled projected messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const transfer = yield* SessionTransfer.Service
|
||||
const { db } = yield* Database.Service
|
||||
const template = yield* session.create({ location, title: "Transfer source" })
|
||||
const sessionID = Session.ID.create()
|
||||
const userID = SessionMessage.ID.create()
|
||||
const runningAssistantID = SessionMessage.ID.create()
|
||||
const completedAssistantID = SessionMessage.ID.create()
|
||||
const runningShellID = SessionMessage.ID.create()
|
||||
const completedShellID = SessionMessage.ID.create()
|
||||
const runningCompactionID = SessionMessage.ID.create()
|
||||
const completedCompactionID = SessionMessage.ID.create()
|
||||
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
|
||||
|
||||
yield* transfer.import({
|
||||
data: {
|
||||
info: { ...template, id: sessionID },
|
||||
messages: [
|
||||
{ id: userID, type: "user", text: "Settled", time: { created: DateTime.makeUnsafe(1) } },
|
||||
{
|
||||
id: runningAssistantID,
|
||||
type: "assistant",
|
||||
agent: Agent.ID.make("build"),
|
||||
model,
|
||||
content: [],
|
||||
time: { created: DateTime.makeUnsafe(2) },
|
||||
},
|
||||
{
|
||||
id: completedAssistantID,
|
||||
type: "assistant",
|
||||
agent: Agent.ID.make("build"),
|
||||
model,
|
||||
content: [],
|
||||
time: { created: DateTime.makeUnsafe(3), completed: DateTime.makeUnsafe(4) },
|
||||
},
|
||||
{
|
||||
id: runningShellID,
|
||||
type: "shell",
|
||||
shellID: Shell.ID.make("sh_transfer_running"),
|
||||
command: "sleep 10",
|
||||
status: "running",
|
||||
time: { created: DateTime.makeUnsafe(5) },
|
||||
},
|
||||
{
|
||||
id: completedShellID,
|
||||
type: "shell",
|
||||
shellID: Shell.ID.make("sh_transfer_completed"),
|
||||
command: "pwd",
|
||||
status: "exited",
|
||||
exit: 0,
|
||||
output: { output: "/project", cursor: 8, size: 8, truncated: false },
|
||||
time: { created: DateTime.makeUnsafe(6), completed: DateTime.makeUnsafe(7) },
|
||||
},
|
||||
{
|
||||
id: runningCompactionID,
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: "manual",
|
||||
summary: "pending",
|
||||
recent: "pending",
|
||||
time: { created: DateTime.makeUnsafe(8) },
|
||||
},
|
||||
{
|
||||
id: completedCompactionID,
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "manual",
|
||||
summary: "summary",
|
||||
recent: "recent",
|
||||
time: { created: DateTime.makeUnsafe(9) },
|
||||
},
|
||||
],
|
||||
},
|
||||
location,
|
||||
})
|
||||
|
||||
expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.id)).toEqual([
|
||||
userID,
|
||||
completedAssistantID,
|
||||
completedShellID,
|
||||
completedCompactionID,
|
||||
])
|
||||
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("imports projected messages and reserves their aggregate sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -1164,15 +900,7 @@ describe("SessionTransfer", () => {
|
||||
|
||||
const imported = yield* transfer.import({
|
||||
data: {
|
||||
info: {
|
||||
...template,
|
||||
id: sessionID,
|
||||
time: {
|
||||
...template.time,
|
||||
idle: DateTime.makeUnsafe(200),
|
||||
viewed: DateTime.makeUnsafe(150),
|
||||
},
|
||||
},
|
||||
info: { ...template, id: sessionID },
|
||||
messages: [
|
||||
{
|
||||
id: sourceMessageID,
|
||||
@@ -1195,18 +923,13 @@ describe("SessionTransfer", () => {
|
||||
const messages = yield* session.messages({ sessionID, order: "asc" })
|
||||
|
||||
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
|
||||
expect(imported.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
|
||||
expect(messages).toMatchObject([
|
||||
{ id: sourceMessageID, type: "user", text: "Imported message" },
|
||||
{ id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } },
|
||||
])
|
||||
expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
|
||||
const exported = yield* transfer.export({ sessionID })
|
||||
expect(exported.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
|
||||
expect(exported.messages).toEqual(messages)
|
||||
const sanitized = yield* transfer.export({ sessionID, sanitize: true })
|
||||
expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
|
||||
expect(sanitized.messages).toMatchObject([
|
||||
expect((yield* transfer.export({ sessionID })).messages).toEqual(messages)
|
||||
expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([
|
||||
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
|
||||
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
|
||||
])
|
||||
@@ -1235,31 +958,4 @@ describe("SessionTransfer", () => {
|
||||
expect(yield* session.messages({ sessionID: existing.id })).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("clamps an imported viewed watermark to its idle transition", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const transfer = yield* SessionTransfer.Service
|
||||
const template = yield* session.create({ location })
|
||||
const imported = yield* transfer.import({
|
||||
data: {
|
||||
info: {
|
||||
...template,
|
||||
id: Session.ID.create(),
|
||||
outcome: "succeeded",
|
||||
time: {
|
||||
...template.time,
|
||||
idle: DateTime.makeUnsafe(200),
|
||||
viewed: DateTime.makeUnsafe(250),
|
||||
},
|
||||
},
|
||||
messages: [],
|
||||
},
|
||||
location,
|
||||
})
|
||||
|
||||
expect(imported.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(200) })
|
||||
expect(imported.outcome).toBe("succeeded")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { mkdir, rm } from "fs/promises"
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -30,47 +28,8 @@ const it = testEffect(
|
||||
],
|
||||
),
|
||||
)
|
||||
const unavailableLocations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
() => Layer.effectDiscard(Effect.fail(new Error("broken location"))) as unknown as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
const itWithUnavailableDestination = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[LocationServiceMap.node, unavailableLocations],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("Session.move", () => {
|
||||
itWithUnavailableDestination.effect("rejects an unavailable destination before admitting the move", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const source = AbsolutePath.make(path.join(tmp.path, "source"))
|
||||
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
|
||||
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
|
||||
const created = yield* session.create({ location: Location.Ref.make({ directory: source }) })
|
||||
|
||||
const error = yield* session.move({ sessionID: created.id, directory: destination }).pipe(Effect.flip)
|
||||
|
||||
expect(error).toEqual(new Session.DestinationUnavailableError({ directory: destination }))
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(source)
|
||||
expect(yield* session.inbox(created.id)).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies a move immediately when the source directory no longer exists", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -74,7 +74,7 @@ const locations = Layer.effect(
|
||||
}),
|
||||
Layer.mock(Snapshot.Service, {
|
||||
capture: () =>
|
||||
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
ready ? Effect.succeed(undefined) : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
restore: () =>
|
||||
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
}),
|
||||
|
||||
@@ -88,16 +88,16 @@ const config = Config.testLayer()
|
||||
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
|
||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.undefined,
|
||||
get: () => Effect.succeed(undefined),
|
||||
all: () => Effect.succeed([]),
|
||||
available: () => Effect.succeed([]),
|
||||
},
|
||||
model: {
|
||||
get: () => Effect.undefined,
|
||||
get: () => Effect.succeed(undefined),
|
||||
all: () => Effect.succeed([]),
|
||||
available: () => Effect.succeed([]),
|
||||
default: () => Effect.undefined,
|
||||
small: () => Effect.undefined,
|
||||
default: () => Effect.succeed(undefined),
|
||||
small: () => Effect.succeed(undefined),
|
||||
},
|
||||
})
|
||||
const runnerLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
|
||||
@@ -378,16 +378,16 @@ const pluginSupervisor = Layer.succeed(
|
||||
)
|
||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.undefined,
|
||||
get: () => Effect.succeed(undefined),
|
||||
all: () => Effect.succeed([]),
|
||||
available: () => Effect.succeed([]),
|
||||
},
|
||||
model: {
|
||||
get: () => Effect.undefined,
|
||||
get: () => Effect.succeed(undefined),
|
||||
all: () => Effect.succeed([]),
|
||||
available: () => Effect.succeed([]),
|
||||
default: () => Effect.undefined,
|
||||
small: () => Effect.undefined,
|
||||
default: () => Effect.succeed(undefined),
|
||||
small: () => Effect.succeed(undefined),
|
||||
},
|
||||
})
|
||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { DateTime, Effect, Layer } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
|
||||
describe("Session.view", () => {
|
||||
it.effect("copies the latest idle time without changing session recency", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
expect(created.time.idle).toBeUndefined()
|
||||
expect(created.time.viewed).toBeUndefined()
|
||||
expect(created.outcome).toBeUndefined()
|
||||
|
||||
yield* session.view({ sessionID: created.id, idle: 0 })
|
||||
expect((yield* session.get(created.id)).time.viewed).toBeUndefined()
|
||||
|
||||
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
|
||||
const idle = yield* session.get(created.id)
|
||||
expect(idle.time.idle).toBeDefined()
|
||||
expect(idle.time.viewed).toBeUndefined()
|
||||
expect(idle.time.updated).toEqual(created.time.updated)
|
||||
expect(idle.outcome).toBe("succeeded")
|
||||
|
||||
if (!idle.time.idle) return yield* Effect.die(new Error("Expected idle time"))
|
||||
yield* session.view({ sessionID: created.id, idle: DateTime.toEpochMillis(idle.time.idle) })
|
||||
const viewed = yield* session.get(created.id)
|
||||
if (!viewed.time.idle || !viewed.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
|
||||
expect(viewed.time.viewed).toEqual(viewed.time.idle)
|
||||
expect(viewed.time.updated).toEqual(created.time.updated)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, created.id))
|
||||
.get(),
|
||||
).toEqual({
|
||||
idle: DateTime.toEpochMillis(viewed.time.idle),
|
||||
viewed: DateTime.toEpochMillis(viewed.time.viewed),
|
||||
})
|
||||
expect((yield* session.list()).data.find((item) => item.id === created.id)?.time).toEqual(viewed.time)
|
||||
|
||||
yield* session.view({ sessionID: created.id, idle: DateTime.toEpochMillis(viewed.time.idle) })
|
||||
expect((yield* session.get(created.id)).time).toEqual(viewed.time)
|
||||
|
||||
yield* bus.publish(SessionEvent.Execution.Failed, {
|
||||
sessionID: created.id,
|
||||
error: { type: "unknown", message: "failed" },
|
||||
})
|
||||
const unread = yield* session.get(created.id)
|
||||
if (!unread.time.idle || !unread.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
|
||||
expect(DateTime.toEpochMillis(unread.time.idle)).toBeGreaterThan(DateTime.toEpochMillis(unread.time.viewed))
|
||||
expect(unread.outcome).toBe("failed")
|
||||
|
||||
yield* session.view({ sessionID: created.id, idle: DateTime.toEpochMillis(unread.time.idle) })
|
||||
expect((yield* session.get(created.id)).time.viewed).toEqual(unread.time.idle)
|
||||
|
||||
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "shutdown" })
|
||||
expect((yield* session.get(created.id)).time.idle).toEqual(unread.time.idle)
|
||||
expect((yield* session.get(created.id)).outcome).toBe("failed")
|
||||
|
||||
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "user" })
|
||||
const interrupted = yield* session.get(created.id)
|
||||
if (!interrupted.time.idle || !interrupted.time.viewed)
|
||||
return yield* Effect.die(new Error("Expected attention times"))
|
||||
expect(DateTime.toEpochMillis(interrupted.time.idle)).toBeGreaterThan(
|
||||
DateTime.toEpochMillis(interrupted.time.viewed),
|
||||
)
|
||||
expect(interrupted.outcome).toBe("interrupted")
|
||||
expect(
|
||||
(yield* db
|
||||
.select({ type: EventTable.type })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, created.id))
|
||||
.all()).filter((event) => event.type === Bus.versionedType(SessionEvent.Viewed.type, 1)),
|
||||
).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps a newer completion unread when the viewed watermark is stale", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
|
||||
const observed = (yield* session.get(created.id)).time.idle
|
||||
if (!observed) return yield* Effect.die(new Error("Expected idle time"))
|
||||
|
||||
// A failure commits between the viewer's observation and the viewed event.
|
||||
yield* bus.publish(SessionEvent.Execution.Failed, {
|
||||
sessionID: created.id,
|
||||
error: { type: "unknown", message: "failed" },
|
||||
})
|
||||
yield* session.view({ sessionID: created.id, idle: DateTime.toEpochMillis(observed) })
|
||||
const stale = yield* session.get(created.id)
|
||||
if (!stale.time.idle || !stale.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
|
||||
expect(stale.time.viewed).toEqual(observed)
|
||||
expect(DateTime.toEpochMillis(stale.time.idle)).toBeGreaterThan(DateTime.toEpochMillis(stale.time.viewed))
|
||||
|
||||
yield* session.view({ sessionID: created.id, idle: DateTime.toEpochMillis(stale.time.idle) + 1 })
|
||||
expect((yield* session.get(created.id)).time.viewed).toEqual(observed)
|
||||
|
||||
// A duplicate stale watermark never regresses a newer acknowledgement.
|
||||
yield* session.view({ sessionID: created.id, idle: DateTime.toEpochMillis(stale.time.idle) })
|
||||
const acked = yield* session.get(created.id)
|
||||
expect(acked.time.viewed).toEqual(acked.time.idle)
|
||||
yield* bus.publish(SessionEvent.Viewed, { sessionID: created.id, idle: DateTime.toEpochMillis(observed) })
|
||||
expect((yield* session.get(created.id)).time.viewed).toEqual(acked.time.viewed)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects an unknown session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const sessionID = Session.ID.make("ses_missing_view")
|
||||
expect(yield* Effect.flip(session.view({ sessionID, idle: 0 }))).toEqual(new Session.NotFoundError({ sessionID }))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays viewed state into a fresh database", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const sourceDb = (yield* Database.Service).db
|
||||
const created = yield* session.create({ id: Session.ID.make("ses_view_replay"), location })
|
||||
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
|
||||
const idle = (yield* session.get(created.id)).time.idle
|
||||
if (!idle) return yield* Effect.die(new Error("Expected idle time"))
|
||||
yield* session.view({ sessionID: created.id, idle: DateTime.toEpochMillis(idle) })
|
||||
yield* bus.publish(SessionEvent.Execution.Failed, {
|
||||
sessionID: created.id,
|
||||
error: { type: "unknown", message: "failed" },
|
||||
})
|
||||
const expected = yield* session.get(created.id)
|
||||
if (!expected.time.idle || !expected.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
|
||||
const expectedIdle = DateTime.toEpochMillis(expected.time.idle)
|
||||
const expectedViewed = DateTime.toEpochMillis(expected.time.viewed)
|
||||
const serialized = (yield* sourceDb
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, created.id))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)).map((event) => ({
|
||||
id: event.id,
|
||||
created: event.created,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
}))
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const targetLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
|
||||
[
|
||||
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
],
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const targetBus = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: location.directory, sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* Effect.forEach(serialized, (event) => targetBus.replay(event), { discard: true })
|
||||
|
||||
const replayed = yield* store.get(created.id)
|
||||
expect(replayed?.time).toEqual(expected.time)
|
||||
expect(replayed?.outcome).toBe("failed")
|
||||
expect(expected.time.updated).toEqual(created.time.updated)
|
||||
expect(expectedIdle).toBeGreaterThan(expectedViewed)
|
||||
}).pipe(Effect.provide(Layer.fresh(targetLayer)))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ShellScan } from "@opencode-ai/shell-scan"
|
||||
import { Effect } from "effect"
|
||||
import { ShellParse } from "../src/shell/parse.js"
|
||||
import { ShellScan } from "../src/shell/scan.js"
|
||||
|
||||
describe("ShellParse portable parity", () => {
|
||||
test("matches tree-sitter for generated supported syntax", async () => {
|
||||
|
||||
@@ -60,9 +60,6 @@ const session = (
|
||||
model: null,
|
||||
time_created: 1,
|
||||
time_updated: 2,
|
||||
time_idle: null,
|
||||
time_viewed: null,
|
||||
idle_outcome: null,
|
||||
time_compacting: 3,
|
||||
time_archived: null,
|
||||
time_suspended: null,
|
||||
|
||||
@@ -216,17 +216,12 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Constrain
|
||||
const modules = new Set(["client", "client-error", "index"])
|
||||
const groups = Array.from(
|
||||
Map.groupBy(endpoints, (endpoint) => endpoint.group),
|
||||
([identifier, endpoints]) => {
|
||||
([identifier, endpoints], index) => {
|
||||
if (new Set(endpoints.map((endpoint) => endpoint.sourceGroup)).size > 1) {
|
||||
throw new GenerationError({ reason: `Client group name collision: ${identifier}` })
|
||||
}
|
||||
// Module names derive from the group identifier so unrelated groups never rename.
|
||||
const sanitized = identifier.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "")
|
||||
const reserved = /^(aux|client|client-error|con|index|nul|prn|com[1-9]|lpt[1-9])$/i.test(sanitized)
|
||||
const module = sanitized === "" || reserved ? `group-${sanitized}` : sanitized
|
||||
if (modules.has(module.toLowerCase())) {
|
||||
throw new GenerationError({ reason: `Client module name collision: ${module}` })
|
||||
}
|
||||
const base = /^[A-Za-z0-9_-]+$/.test(identifier) ? identifier : `group-${index}`
|
||||
const module = uniqueModule(base, index, modules)
|
||||
modules.add(module.toLowerCase())
|
||||
return { identifier, sourceIdentifier: endpoints[0].sourceGroup, module, endpoints }
|
||||
},
|
||||
@@ -368,28 +363,9 @@ function renderEffectShape(
|
||||
) {
|
||||
const references = effectTypeReferences(typeReferences)
|
||||
const imports = new Set<string>()
|
||||
const externalNames = new Set([
|
||||
"AppApi",
|
||||
"Effect",
|
||||
"Stream",
|
||||
...typeReferences.flatMap((reference) => reference.name.match(/^[A-Za-z_$][A-Za-z0-9_$]*/) ?? []),
|
||||
...Object.values(outputTypes ?? {}).flatMap((output) => output.name.match(/^[A-Za-z_$][A-Za-z0-9_$]*/) ?? []),
|
||||
])
|
||||
const generatedNames = groups.flatMap((group) => [
|
||||
groupShapeName(group),
|
||||
...group.endpoints.flatMap((endpoint) => [
|
||||
...(endpoint.operation.inputMode === "none" ? [] : [`${endpointTypeName(group, endpoint)}Input`]),
|
||||
`${endpointTypeName(group, endpoint)}Output`,
|
||||
groupShapeTypeName(group, endpoint),
|
||||
]),
|
||||
])
|
||||
const collision = generatedNames.find((name) => externalNames.has(name))
|
||||
if (collision !== undefined) {
|
||||
throw new GenerationError({ reason: `Generated Effect type collides with imported type: ${collision}` })
|
||||
}
|
||||
const endpointTypes = groups.map((group) => {
|
||||
const endpoints = group.endpoints.map((endpoint) => {
|
||||
const prefix = endpointTypeName(group, endpoint)
|
||||
const endpointTypes = groups.map((group, groupIndex) => {
|
||||
const endpoints = group.endpoints.map((endpoint, endpointIndex) => {
|
||||
const prefix = `Endpoint${groupIndex}_${endpointIndex}`
|
||||
const input = endpoint.input
|
||||
.map((field) => {
|
||||
const schema = effectInputSchema(endpoint, field)
|
||||
@@ -554,23 +530,8 @@ function groupShapeName(group: Group) {
|
||||
return `${identifierPart(group.identifier)}Api`
|
||||
}
|
||||
|
||||
// Generated symbol names derive from group and endpoint identity, never from traversal
|
||||
// position, so adding an endpoint or group cannot rename unrelated generated code.
|
||||
// Uniqueness is validated by compile (groupTypeNames/endpointTypeNames).
|
||||
function groupTypeName(group: Group) {
|
||||
return identifierPart(group.identifier)
|
||||
}
|
||||
|
||||
function endpointTypeName(group: Group, endpoint: Endpoint) {
|
||||
return `${groupTypeName(group)}${endpoint.clientPath.map(identifierPart).join("")}`
|
||||
}
|
||||
|
||||
function endpointAdapterName(group: Group, endpoint: Endpoint) {
|
||||
return `Endpoint${endpointTypeName(group, endpoint)}`
|
||||
}
|
||||
|
||||
function groupShapeTypeName(group: Group, endpoint: Endpoint) {
|
||||
return `${endpointTypeName(group, endpoint)}Operation`
|
||||
return `${identifierPart(group.identifier)}${endpoint.clientPath.map(identifierPart).join("")}Operation`
|
||||
}
|
||||
|
||||
function assertPromiseEndpoint(endpoint: Endpoint) {
|
||||
@@ -624,7 +585,7 @@ function promiseOperations(groups: ReadonlyArray<Group>) {
|
||||
|
||||
function renderEffectFiles(groups: ReadonlyArray<Group>): Output["files"] {
|
||||
return [
|
||||
...groups.map((group) => ({ path: `${group.module}.ts`, content: renderGroup(group) })),
|
||||
...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })),
|
||||
{
|
||||
path: "client-error.ts",
|
||||
content:
|
||||
@@ -649,11 +610,10 @@ function renderImportedEffectFiles(
|
||||
readonly shapeModule?: string
|
||||
},
|
||||
): Output["files"] {
|
||||
const adapters = groups.map((group) => {
|
||||
const adapters = groups.map((group, groupIndex) => {
|
||||
const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]`
|
||||
const methods = group.endpoints.map((item) => {
|
||||
const prefix = endpointTypeName(group, item)
|
||||
const adapter = endpointAdapterName(group, item)
|
||||
const methods = group.endpoints.map((item, endpointIndex) => {
|
||||
const prefix = `Endpoint${groupIndex}_${endpointIndex}`
|
||||
const schemaBySource = {
|
||||
params: item.params,
|
||||
query: item.query,
|
||||
@@ -700,22 +660,20 @@ function renderImportedEffectFiles(
|
||||
: isOpaquePayload(item)
|
||||
? `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.identifier)}]>[0]\n`
|
||||
: ""
|
||||
return `${declarations}const ${adapter} = (raw: ${rawGroup}) => (${argument}) => ${output}`
|
||||
return `${declarations}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${output}`
|
||||
})
|
||||
const fields = renderClientTree(
|
||||
group.endpoints,
|
||||
(item) => `${endpointAdapterName(group, item)}(raw)`,
|
||||
(_item, endpointIndex) => `Endpoint${groupIndex}_${endpointIndex}(raw)`,
|
||||
(name, value) => `${JSON.stringify(name)}: ${value}`,
|
||||
", ",
|
||||
)
|
||||
return `${methods.join("\n\n")}\n\nconst adaptGroup${groupTypeName(group)} = (raw: ${rawGroup}) => ({ ${fields} })`
|
||||
return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${fields} })`
|
||||
})
|
||||
const fields = groups.flatMap((group) =>
|
||||
const fields = groups.flatMap((group, index) =>
|
||||
group.endpoints[0]?.topLevel
|
||||
? [`...adaptGroup${groupTypeName(group)}(raw)`]
|
||||
: [
|
||||
`${JSON.stringify(group.identifier)}: adaptGroup${groupTypeName(group)}(raw[${JSON.stringify(group.sourceIdentifier)}])`,
|
||||
],
|
||||
? [`...adaptGroup${index}(raw)`]
|
||||
: [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.sourceIdentifier)}])`],
|
||||
)
|
||||
const usesStream = groups.some((group) => group.endpoints.some((item) => item.operation.success === "stream"))
|
||||
const imported = "api" in options
|
||||
@@ -725,24 +683,15 @@ function renderImportedEffectFiles(
|
||||
? renderImportedGroup(options.group)
|
||||
: renderImportedProjection(groups, options.endpoints)
|
||||
const api = imported ? options.api : "Api"
|
||||
const adapterNames = new Set(
|
||||
groups.flatMap((group) => group.endpoints.map((endpoint) => endpointAdapterName(group, endpoint))),
|
||||
)
|
||||
const adapterCollision = (projection?.imports ?? [api]).find((name) => adapterNames.has(name))
|
||||
if (adapterCollision !== undefined) {
|
||||
throw new GenerationError({
|
||||
reason: `Generated Effect adapter collides with imported endpoint: ${adapterCollision}`,
|
||||
})
|
||||
}
|
||||
const imports =
|
||||
projection === undefined
|
||||
? `import { ${api} } from ${JSON.stringify(options.module)}`
|
||||
: `import { HttpApi, HttpApiClient${"endpoints" in options ? ", HttpApiGroup" : ""} } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}`
|
||||
const httpApiImport = projection === undefined ? 'import { HttpApiClient } from "effect/unstable/httpapi"\n' : ""
|
||||
const shapeTypes = groups.flatMap((group) =>
|
||||
group.endpoints.flatMap((endpoint) => [
|
||||
...(endpoint.operation.inputMode === "none" ? [] : [`${endpointTypeName(group, endpoint)}Input`]),
|
||||
`${endpointTypeName(group, endpoint)}Output`,
|
||||
const shapeTypes = groups.flatMap((group, groupIndex) =>
|
||||
group.endpoints.flatMap((endpoint, endpointIndex) => [
|
||||
...(endpoint.operation.inputMode === "none" ? [] : [`Endpoint${groupIndex}_${endpointIndex}Input`]),
|
||||
`Endpoint${groupIndex}_${endpointIndex}Output`,
|
||||
]),
|
||||
)
|
||||
const shapeImport =
|
||||
@@ -1026,12 +975,11 @@ function renderClientTree(
|
||||
}
|
||||
|
||||
function identifierPart(value: string) {
|
||||
const identifier = value
|
||||
return value
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
|
||||
.join("")
|
||||
return /^[A-Za-z_$]/.test(identifier) ? identifier : `_${identifier}`
|
||||
}
|
||||
|
||||
function structuralTypes(schemas: ReadonlyArray<Schema.Top>, mutable: boolean, reservedNames: ReadonlySet<string>) {
|
||||
@@ -1291,6 +1239,14 @@ function promisePath(path: string, input: ReadonlyArray<InputField>, wildcard?:
|
||||
return `\`${template}${wildcard === undefined ? "" : `\${encodePath(input.${wildcard.name})}`}\``
|
||||
}
|
||||
|
||||
function uniqueModule(base: string, index: number, modules: ReadonlySet<string>) {
|
||||
if (!modules.has(base.toLowerCase())) return base
|
||||
const seed = `${base}-${index}`
|
||||
let suffix = 0
|
||||
while (modules.has(`${seed}${suffix === 0 ? "" : `-${suffix}`}`.toLowerCase())) suffix++
|
||||
return `${seed}${suffix === 0 ? "" : `-${suffix}`}`
|
||||
}
|
||||
|
||||
function normalizeTransport(
|
||||
schema: Schema.Top | undefined,
|
||||
source: InputField["source"] | "success" | "error",
|
||||
@@ -1470,7 +1426,7 @@ export function write(
|
||||
output.files,
|
||||
(file) =>
|
||||
fs.exists(join(directory, file.path)).pipe(
|
||||
Effect.flatMap((exists) => (exists ? fs.stat(join(directory, file.path)) : Effect.undefined)),
|
||||
Effect.flatMap((exists) => (exists ? fs.stat(join(directory, file.path)) : Effect.succeed(undefined))),
|
||||
Effect.flatMap((info) =>
|
||||
info?.type === "SymbolicLink"
|
||||
? new GenerationError({ reason: `Unsafe output path: ${file.path}` })
|
||||
@@ -1741,10 +1697,10 @@ function streamEffectPortable(schema: Schema.Top) {
|
||||
return sameEncoding(schema.events.ast, rebuilt.events.ast)
|
||||
}
|
||||
|
||||
function renderGroup(group: Group) {
|
||||
function renderGroup(group: Group, groupIndex: number) {
|
||||
const slots: Array<Slot> = []
|
||||
const adapters: Array<string> = []
|
||||
const endpointSources = group.endpoints.map((operation) => {
|
||||
const endpointSources = group.endpoints.map((operation, endpointIndex) => {
|
||||
const {
|
||||
endpoint,
|
||||
errors,
|
||||
@@ -1754,7 +1710,7 @@ function renderGroup(group: Group) {
|
||||
query: endpointQuery,
|
||||
successes,
|
||||
} = operation
|
||||
const prefix = `Endpoint${operation.clientPath.map(identifierPart).join("")}`
|
||||
const prefix = `Endpoint${endpointIndex}`
|
||||
const params = addSlot(endpointParams, `${prefix}Params`)
|
||||
const query = addSlot(endpointQuery, `${prefix}Query`)
|
||||
const headers = addSlot(endpointHeaders, `${prefix}Headers`)
|
||||
@@ -1849,16 +1805,15 @@ function renderGroup(group: Group) {
|
||||
const usesHttpApiSchema = endpointSources.some((source) => source.includes("HttpApiSchema."))
|
||||
const methods = renderClientTree(
|
||||
group.endpoints,
|
||||
(item) => `Endpoint${item.clientPath.map(identifierPart).join("")}(raw)`,
|
||||
(_item, index) => `Endpoint${index}(raw)`,
|
||||
(name, value) => `${JSON.stringify(name)}: ${value}`,
|
||||
", ",
|
||||
)
|
||||
const name = groupTypeName(group)
|
||||
const rawGroup = group.endpoints[0]?.topLevel
|
||||
? `HttpApiClient.Client<typeof Group${name}>`
|
||||
: `HttpApiClient.Client.Group<typeof Group${name}, never, never>`
|
||||
? `HttpApiClient.Client<typeof Group${groupIndex}>`
|
||||
: `HttpApiClient.Client.Group<typeof Group${groupIndex}, never, never>`
|
||||
const usesStream = group.endpoints.some((item) => item.operation.success === "stream")
|
||||
return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error.js"\n\n${declarations}\n\nexport const Group${name} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${name} = (raw: RawGroup) => ({ ${methods} })\n`
|
||||
return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error.js"\n\n${declarations}\n\nexport const Group${groupIndex} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${groupIndex} = (raw: RawGroup) => ({ ${methods} })\n`
|
||||
}
|
||||
|
||||
function renderEffectRequestPart(
|
||||
@@ -1928,20 +1883,15 @@ function renderSchemas(slots: ReadonlyArray<Slot>) {
|
||||
|
||||
function renderClient(groups: ReadonlyArray<Group>) {
|
||||
const imports = groups
|
||||
.map(
|
||||
(group) =>
|
||||
`import { adaptGroup${groupTypeName(group)}, Group${groupTypeName(group)} } from ${JSON.stringify(`./${group.module}`)}`,
|
||||
)
|
||||
.map((group, index) => `import { adaptGroup${index}, Group${index} } from ${JSON.stringify(`./${group.module}`)}`)
|
||||
.join("\n")
|
||||
const api = `HttpApi.make("generated")${groups.map((group) => `.add(Group${groupTypeName(group)})`).join("")}`
|
||||
const fields = groups.flatMap((group) => {
|
||||
const api = `HttpApi.make("generated")${groups.map((_, index) => `.add(Group${index})`).join("")}`
|
||||
const fields = groups.flatMap((group, index) => {
|
||||
if (!group.endpoints[0]?.topLevel) {
|
||||
return [
|
||||
`${JSON.stringify(group.identifier)}: adaptGroup${groupTypeName(group)}(raw[${JSON.stringify(group.identifier)}])`,
|
||||
]
|
||||
return [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.identifier)}])`]
|
||||
}
|
||||
const raw = `{ ${group.endpoints.map((item) => `${JSON.stringify(item.endpoint.identifier)}: raw[${JSON.stringify(item.endpoint.identifier)}]`).join(", ")} }`
|
||||
return [`...adaptGroup${groupTypeName(group)}(${raw})`]
|
||||
return [`...adaptGroup${index}(${raw})`]
|
||||
})
|
||||
return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect } from "effect"\nimport { HttpApi, HttpApiClient } from "effect/unstable/httpapi"\n${imports}\n\nconst Api = ${api}\nconst adaptClient = (raw: HttpApiClient.ForApi<typeof Api>) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) =>\n HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))\n`
|
||||
}
|
||||
|
||||
@@ -120,8 +120,8 @@ describe("HttpApiCodegen.generate", () => {
|
||||
const source = output.files[0]?.content
|
||||
|
||||
expect(source).toContain('import type { Session } from "@example/schema/session"')
|
||||
expect(source).toContain('export type SessionGetInput = { readonly "id": string }')
|
||||
expect(source).toContain("export type SessionGetOutput = Session.Info")
|
||||
expect(source).toContain('export type Endpoint0_0Input = { readonly "id": string }')
|
||||
expect(source).toContain("export type Endpoint0_0Output = Session.Info")
|
||||
expect(source).not.toContain("HttpApiClient")
|
||||
expect(source).not.toContain("@example/api")
|
||||
})
|
||||
@@ -141,53 +141,7 @@ describe("HttpApiCodegen.generate", () => {
|
||||
const source = output.files[0]?.content
|
||||
|
||||
expect(source).toContain('import type { OpenCodeEvent } from "@example/protocol/event"')
|
||||
expect(source).toContain("export type SessionEventsOutput = OpenCodeEvent")
|
||||
})
|
||||
|
||||
test("rejects authoritative Effect types colliding with generated aliases", () => {
|
||||
expect(() =>
|
||||
emitEffectShape(compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))), {
|
||||
outputTypes: {
|
||||
"session.get": {
|
||||
name: "SessionGetOutput",
|
||||
import: 'import type { SessionGetOutput } from "@example/schema/session"',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow("Generated Effect type collides with imported type: SessionGetOutput")
|
||||
})
|
||||
|
||||
test("rejects qualified Effect imports colliding with generated interfaces", () => {
|
||||
const Info = Schema.Struct({ id: Schema.String }).annotate({ identifier: "Session.Info" })
|
||||
|
||||
expect(() =>
|
||||
emitEffectShape(compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Info }))), {
|
||||
typeReferences: [
|
||||
{
|
||||
schema: Info,
|
||||
name: "SessionApi.Info",
|
||||
import: 'import type { SessionApi } from "@example/schema/session"',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("Generated Effect type collides with imported type: SessionApi")
|
||||
})
|
||||
|
||||
test("rejects imported endpoints colliding with generated adapter values", () => {
|
||||
const contract = compileContract(api(HttpApiEndpoint.get("session.get", "/session", { success: Schema.String })))
|
||||
|
||||
expect(() =>
|
||||
emitEffectImported(contract, {
|
||||
module: "@example/api",
|
||||
endpoints: { "session.session.get": "EndpointSessionGet" },
|
||||
}),
|
||||
).toThrow("Generated Effect adapter collides with imported endpoint: EndpointSessionGet")
|
||||
expect(() =>
|
||||
emitEffectImported(contract, {
|
||||
module: "@example/api",
|
||||
api: "EndpointSessionGet",
|
||||
}),
|
||||
).toThrow("Generated Effect adapter collides with imported endpoint: EndpointSessionGet")
|
||||
expect(source).toContain("export type Endpoint0_0Output = OpenCodeEvent")
|
||||
})
|
||||
|
||||
test("exposes an imported Effect client through its generated shape", () => {
|
||||
@@ -197,8 +151,8 @@ describe("HttpApiCodegen.generate", () => {
|
||||
)
|
||||
const source = output.files.find((file) => file.path === "client.ts")?.content
|
||||
|
||||
expect(source).toContain('import type { SessionGetOutput } from "../api"')
|
||||
expect(source).toContain("preserveEffect<SessionGetOutput>()")
|
||||
expect(source).toContain('import type { Endpoint0_0Output } from "../api"')
|
||||
expect(source).toContain("preserveEffect<Endpoint0_0Output>()")
|
||||
})
|
||||
|
||||
test("projects imported endpoint constants into a generated API", () => {
|
||||
@@ -317,12 +271,12 @@ describe("HttpApiCodegen.generate", () => {
|
||||
|
||||
const effect = emitEffect(contract)
|
||||
expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain(
|
||||
'"instructions": { "list": EndpointInstructionsList(raw), "put": EndpointInstructionsPut(raw), "remove": EndpointInstructionsRemove(raw) }',
|
||||
'"instructions": { "list": Endpoint0(raw), "put": Endpoint1(raw), "remove": Endpoint2(raw) }',
|
||||
)
|
||||
|
||||
const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" })
|
||||
expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain(
|
||||
'"instructions": { "list": EndpointSessionInstructionsList(raw), "put": EndpointSessionInstructionsPut(raw), "remove": EndpointSessionInstructionsRemove(raw) }',
|
||||
'"instructions": { "list": Endpoint0_0(raw), "put": Endpoint0_1(raw), "remove": Endpoint0_2(raw) }',
|
||||
)
|
||||
|
||||
const shape = emitEffectShape(contract)
|
||||
@@ -442,13 +396,8 @@ describe("HttpApiCodegen.generate", () => {
|
||||
})
|
||||
|
||||
test("rejects normalized group, operation-key, and group prototype collisions", () => {
|
||||
const sanitized = HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("foo-bar").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("foo.bar").add(HttpApiEndpoint.get("get", "/second", { success: Schema.String })))
|
||||
expect(() => compileContract(sanitized)).toThrow("Client module name collision: foo-bar")
|
||||
|
||||
const normalized = HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("foo_bar").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("foo-bar").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("foo.bar").add(HttpApiEndpoint.get("get", "/second", { success: Schema.String })))
|
||||
expect(() => compileContract(normalized)).toThrow("Client group type collision: FooBar")
|
||||
|
||||
@@ -550,9 +499,7 @@ describe("HttpApiCodegen.generate", () => {
|
||||
|
||||
expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get")
|
||||
expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
|
||||
expect(effect).toContain(
|
||||
'const adaptGroupSession = (raw: RawClient["session"]) => ({ "get": EndpointSessionGet(raw) })',
|
||||
)
|
||||
expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })')
|
||||
expect(effect).toContain('raw["session.get"]')
|
||||
})
|
||||
|
||||
@@ -1531,7 +1478,7 @@ describe("HttpApiCodegen.generate", () => {
|
||||
|
||||
expect(output.operations[0]).toBeDefined()
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
|
||||
'extends Schema.TaggedError<EndpointGetError0Class>("Unauthorized")',
|
||||
'extends Schema.TaggedError<Endpoint0Error0Class>("Unauthorized")',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1547,7 +1494,7 @@ describe("HttpApiCodegen.generate", () => {
|
||||
)
|
||||
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
|
||||
'EndpointGetError0Class.annotate({ "httpApiStatus": 404 })',
|
||||
'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1557,35 +1504,35 @@ describe("HttpApiCodegen.generate", () => {
|
||||
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")')
|
||||
})
|
||||
|
||||
test("uses safe identity-derived module paths without changing public group identifiers", () => {
|
||||
test("uses safe unique module paths without changing public group identifiers", () => {
|
||||
const output = compile(
|
||||
HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))),
|
||||
)
|
||||
|
||||
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["session.ts", "GROUP-0.ts"])
|
||||
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"])
|
||||
expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"')
|
||||
})
|
||||
|
||||
test("prefixes group modules that collide with support or Windows-reserved names", () => {
|
||||
test("reserves support module names case-insensitively", () => {
|
||||
const output = compile(
|
||||
HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("CON").add(HttpApiEndpoint.get("get", "/con", { success: Schema.String }))),
|
||||
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))),
|
||||
)
|
||||
|
||||
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-INDEX.ts", "group-CON.ts"])
|
||||
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"])
|
||||
})
|
||||
|
||||
test("rejects module names colliding after normalization", () => {
|
||||
expect(() =>
|
||||
compile(
|
||||
HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("my.group").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("my/group").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
|
||||
),
|
||||
).toThrow("Client module name collision: my-group")
|
||||
test("keeps searching when a reserved-name fallback is also occupied", () => {
|
||||
const output = compile(
|
||||
HttpApi.make("test")
|
||||
.add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
|
||||
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
|
||||
)
|
||||
|
||||
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"])
|
||||
})
|
||||
|
||||
test("rejects collisions in the flattened client namespace", () => {
|
||||
@@ -1611,7 +1558,7 @@ describe("HttpApiCodegen.generate", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof GroupHealth")
|
||||
expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof Group0")
|
||||
})
|
||||
|
||||
it.effect("reports compiler failures in the generate Effect", () =>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import { Effect } from "effect"
|
||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { adaptGroupSession, GroupSession } from "./session"
|
||||
import { adaptGroupEvent, GroupEvent } from "./event"
|
||||
import { adaptGroupSystem, GroupSystem } from "./system"
|
||||
import { adaptGroup0, Group0 } from "./session"
|
||||
import { adaptGroup1, Group1 } from "./event"
|
||||
import { adaptGroup2, Group2 } from "./system"
|
||||
|
||||
const Api = HttpApi.make("generated").add(GroupSession).add(GroupEvent).add(GroupSystem)
|
||||
const Api = HttpApi.make("generated").add(Group0).add(Group1).add(Group2)
|
||||
const adaptClient = (raw: HttpApiClient.ForApi<typeof Api>) => ({
|
||||
session: adaptGroupSession(raw["session"]),
|
||||
event: adaptGroupEvent(raw["event"]),
|
||||
...adaptGroupSystem({ status: raw["status"] }),
|
||||
session: adaptGroup0(raw["session"]),
|
||||
event: adaptGroup1(raw["event"]),
|
||||
...adaptGroup2({ status: raw["status"] }),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
||||
@@ -5,35 +5,35 @@ import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { ClientError } from "./client-error.js"
|
||||
|
||||
const EndpointSubscribeSuccessData = Schema.Struct({ type: Schema.String })
|
||||
const Endpoint0SuccessData = Schema.Struct({ type: Schema.String })
|
||||
|
||||
const EndpointSubscribeSuccessError = Schema.Never
|
||||
const Endpoint0SuccessError = Schema.Never
|
||||
|
||||
export const GroupEvent = HttpApiGroup.make("event", { topLevel: false }).add(
|
||||
export const Group1 = HttpApiGroup.make("event", { topLevel: false }).add(
|
||||
HttpApiEndpoint.make("GET")("subscribe", "/event", {
|
||||
success: HttpApiSchema.StreamSse({
|
||||
data: EndpointSubscribeSuccessData,
|
||||
error: EndpointSubscribeSuccessError,
|
||||
data: Endpoint0SuccessData,
|
||||
error: Endpoint0SuccessError,
|
||||
contentType: "text/event-stream",
|
||||
}).pipe(HttpApiSchema.status(202)),
|
||||
}),
|
||||
)
|
||||
|
||||
type RawGroup = HttpApiClient.Client.Group<typeof GroupEvent, never, never>
|
||||
type RawGroup = HttpApiClient.Client.Group<typeof Group1, never, never>
|
||||
|
||||
const EndpointSubscribeDeclaredError = Schema.Union([EndpointSubscribeSuccessError])
|
||||
const mapEndpointSubscribeError = (error: unknown) =>
|
||||
const Endpoint0DeclaredError = Schema.Union([Endpoint0SuccessError])
|
||||
const mapEndpoint0Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(EndpointSubscribeDeclaredError)(error)
|
||||
: Schema.is(Endpoint0DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const EndpointSubscribe = (raw: RawGroup) => () =>
|
||||
const Endpoint0 = (raw: RawGroup) => () =>
|
||||
Stream.unwrap(
|
||||
raw["subscribe"]({}).pipe(
|
||||
Effect.mapError(mapEndpointSubscribeError),
|
||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpointSubscribeError))),
|
||||
Effect.mapError(mapEndpoint0Error),
|
||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpoint0Error))),
|
||||
),
|
||||
)
|
||||
|
||||
export const adaptGroupEvent = (raw: RawGroup) => ({ subscribe: EndpointSubscribe(raw) })
|
||||
export const adaptGroup1 = (raw: RawGroup) => ({ subscribe: Endpoint0(raw) })
|
||||
|
||||
@@ -5,141 +5,137 @@ import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import { ClientError } from "./client-error.js"
|
||||
|
||||
const EndpointHealthSuccess = Schema.String
|
||||
const Endpoint0Success = Schema.String
|
||||
|
||||
const EndpointListQuery = Schema.Struct({
|
||||
archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])),
|
||||
})
|
||||
const Endpoint1Query = Schema.Struct({ archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) })
|
||||
|
||||
const EndpointListSuccess = Schema.Array(Schema.String)
|
||||
const Endpoint1Success = Schema.Array(Schema.String)
|
||||
|
||||
const EndpointGetParams = Schema.Struct({ sessionID: Schema.String })
|
||||
const Endpoint2Params = Schema.Struct({ sessionID: Schema.String })
|
||||
|
||||
const EndpointGetSuccess = Schema.Struct({ data: Schema.String })
|
||||
const Endpoint2Success = Schema.Struct({ data: Schema.String })
|
||||
|
||||
class EndpointGetError0Class extends Schema.TaggedError<EndpointGetError0Class>("Missing")("Missing", {
|
||||
class Endpoint2Error0Class extends Schema.TaggedError<Endpoint2Error0Class>("Missing")("Missing", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
const EndpointGetError0 = EndpointGetError0Class.annotate({ httpApiStatus: 404 })
|
||||
const Endpoint2Error0 = Endpoint2Error0Class.annotate({ httpApiStatus: 404 })
|
||||
|
||||
const EndpointInterruptParams = Schema.Struct({ sessionID: Schema.String })
|
||||
const Endpoint3Params = Schema.Struct({ sessionID: Schema.String })
|
||||
|
||||
const EndpointInterruptSuccess = Schema.Void.annotate({ httpApiStatus: 204 })
|
||||
const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 })
|
||||
|
||||
const EndpointConfigureParams = Schema.Struct({ sessionID: Schema.String })
|
||||
const Endpoint4Params = Schema.Struct({ sessionID: Schema.String })
|
||||
|
||||
const EndpointConfigureQuery = Schema.Struct({
|
||||
dryRun: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])),
|
||||
})
|
||||
const Endpoint4Query = Schema.Struct({ dryRun: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) })
|
||||
|
||||
const EndpointConfigureHeaders = Schema.Struct({ traceID: Schema.String })
|
||||
const Endpoint4Headers = Schema.Struct({ traceID: Schema.String })
|
||||
|
||||
const EndpointConfigurePayload0 = Schema.Union([
|
||||
const Endpoint4Payload0 = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
|
||||
Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }),
|
||||
])
|
||||
|
||||
const EndpointConfigureSuccess = Schema.String
|
||||
const Endpoint4Success = Schema.String
|
||||
|
||||
export const GroupSession = HttpApiGroup.make("session", { topLevel: false })
|
||||
.add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: EndpointHealthSuccess }))
|
||||
.add(HttpApiEndpoint.make("GET")("list", "/session", { query: EndpointListQuery, success: EndpointListSuccess }))
|
||||
export const Group0 = HttpApiGroup.make("session", { topLevel: false })
|
||||
.add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: Endpoint0Success }))
|
||||
.add(HttpApiEndpoint.make("GET")("list", "/session", { query: Endpoint1Query, success: Endpoint1Success }))
|
||||
.add(
|
||||
HttpApiEndpoint.make("GET")("get", "/session/:sessionID", {
|
||||
params: EndpointGetParams,
|
||||
success: EndpointGetSuccess,
|
||||
error: EndpointGetError0,
|
||||
params: Endpoint2Params,
|
||||
success: Endpoint2Success,
|
||||
error: Endpoint2Error0,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.make("POST")("interrupt", "/session/:sessionID/interrupt", {
|
||||
params: EndpointInterruptParams,
|
||||
success: EndpointInterruptSuccess,
|
||||
params: Endpoint3Params,
|
||||
success: Endpoint3Success,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.make("POST")("configure", "/session/:sessionID/configure", {
|
||||
params: EndpointConfigureParams,
|
||||
query: EndpointConfigureQuery,
|
||||
headers: EndpointConfigureHeaders,
|
||||
payload: EndpointConfigurePayload0,
|
||||
success: EndpointConfigureSuccess,
|
||||
params: Endpoint4Params,
|
||||
query: Endpoint4Query,
|
||||
headers: Endpoint4Headers,
|
||||
payload: Endpoint4Payload0,
|
||||
success: Endpoint4Success,
|
||||
}),
|
||||
)
|
||||
|
||||
type RawGroup = HttpApiClient.Client.Group<typeof GroupSession, never, never>
|
||||
type RawGroup = HttpApiClient.Client.Group<typeof Group0, never, never>
|
||||
|
||||
const EndpointHealthDeclaredError = Schema.Never
|
||||
const mapEndpointHealthError = (error: unknown) =>
|
||||
const Endpoint0DeclaredError = Schema.Never
|
||||
const mapEndpoint0Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(EndpointHealthDeclaredError)(error)
|
||||
: Schema.is(Endpoint0DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const EndpointHealth = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpointHealthError))
|
||||
const Endpoint0 = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpoint0Error))
|
||||
|
||||
type EndpointListInput = { readonly archived?: (typeof EndpointListQuery.Type)["archived"] }
|
||||
const EndpointListDeclaredError = Schema.Never
|
||||
const mapEndpointListError = (error: unknown) =>
|
||||
type Endpoint1Input = { readonly archived?: (typeof Endpoint1Query.Type)["archived"] }
|
||||
const Endpoint1DeclaredError = Schema.Never
|
||||
const mapEndpoint1Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(EndpointListDeclaredError)(error)
|
||||
: Schema.is(Endpoint1DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const EndpointList = (raw: RawGroup) => (input?: EndpointListInput) =>
|
||||
raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpointListError))
|
||||
const Endpoint1 = (raw: RawGroup) => (input?: Endpoint1Input) =>
|
||||
raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpoint1Error))
|
||||
|
||||
type EndpointGetInput = { readonly sessionID: (typeof EndpointGetParams.Type)["sessionID"] }
|
||||
const EndpointGetDeclaredError = Schema.Union([EndpointGetError0])
|
||||
const mapEndpointGetError = (error: unknown) =>
|
||||
type Endpoint2Input = { readonly sessionID: (typeof Endpoint2Params.Type)["sessionID"] }
|
||||
const Endpoint2DeclaredError = Schema.Union([Endpoint2Error0])
|
||||
const mapEndpoint2Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(EndpointGetDeclaredError)(error)
|
||||
: Schema.is(Endpoint2DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const EndpointGet = (raw: RawGroup) => (input: EndpointGetInput) =>
|
||||
const Endpoint2 = (raw: RawGroup) => (input: Endpoint2Input) =>
|
||||
raw["get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapEndpointGetError),
|
||||
Effect.mapError(mapEndpoint2Error),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type EndpointInterruptInput = { readonly sessionID: (typeof EndpointInterruptParams.Type)["sessionID"] }
|
||||
const EndpointInterruptDeclaredError = Schema.Never
|
||||
const mapEndpointInterruptError = (error: unknown) =>
|
||||
type Endpoint3Input = { readonly sessionID: (typeof Endpoint3Params.Type)["sessionID"] }
|
||||
const Endpoint3DeclaredError = Schema.Never
|
||||
const mapEndpoint3Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(EndpointInterruptDeclaredError)(error)
|
||||
: Schema.is(Endpoint3DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const EndpointInterrupt = (raw: RawGroup) => (input: EndpointInterruptInput) =>
|
||||
raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpointInterruptError))
|
||||
const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) =>
|
||||
raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error))
|
||||
|
||||
type EndpointConfigureRequest = Parameters<RawGroup["configure"]>[0]
|
||||
type EndpointConfigureInput = {
|
||||
readonly sessionID: (typeof EndpointConfigureParams.Type)["sessionID"]
|
||||
readonly dryRun?: (typeof EndpointConfigureQuery.Type)["dryRun"]
|
||||
readonly traceID: (typeof EndpointConfigureHeaders.Type)["traceID"]
|
||||
readonly payload: typeof EndpointConfigurePayload0.Type
|
||||
type Endpoint4Request = Parameters<RawGroup["configure"]>[0]
|
||||
type Endpoint4Input = {
|
||||
readonly sessionID: (typeof Endpoint4Params.Type)["sessionID"]
|
||||
readonly dryRun?: (typeof Endpoint4Query.Type)["dryRun"]
|
||||
readonly traceID: (typeof Endpoint4Headers.Type)["traceID"]
|
||||
readonly payload: typeof Endpoint4Payload0.Type
|
||||
}
|
||||
const EndpointConfigureDeclaredError = Schema.Never
|
||||
const mapEndpointConfigureError = (error: unknown) =>
|
||||
const Endpoint4DeclaredError = Schema.Never
|
||||
const mapEndpoint4Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(EndpointConfigureDeclaredError)(error)
|
||||
: Schema.is(Endpoint4DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const EndpointConfigure = (raw: RawGroup) => (input: EndpointConfigureInput) =>
|
||||
const Endpoint4 = (raw: RawGroup) => (input: Endpoint4Input) =>
|
||||
raw["configure"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { dryRun: input["dryRun"] },
|
||||
headers: { traceID: input["traceID"] },
|
||||
payload: input["payload"],
|
||||
} as EndpointConfigureRequest).pipe(Effect.mapError(mapEndpointConfigureError))
|
||||
} as Endpoint4Request).pipe(Effect.mapError(mapEndpoint4Error))
|
||||
|
||||
export const adaptGroupSession = (raw: RawGroup) => ({
|
||||
health: EndpointHealth(raw),
|
||||
list: EndpointList(raw),
|
||||
get: EndpointGet(raw),
|
||||
interrupt: EndpointInterrupt(raw),
|
||||
configure: EndpointConfigure(raw),
|
||||
export const adaptGroup0 = (raw: RawGroup) => ({
|
||||
health: Endpoint0(raw),
|
||||
list: Endpoint1(raw),
|
||||
get: Endpoint2(raw),
|
||||
interrupt: Endpoint3(raw),
|
||||
configure: Endpoint4(raw),
|
||||
})
|
||||
|
||||
@@ -5,21 +5,21 @@ import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import { ClientError } from "./client-error.js"
|
||||
|
||||
const EndpointStatusSuccess = Schema.String
|
||||
const Endpoint0Success = Schema.String
|
||||
|
||||
export const GroupSystem = HttpApiGroup.make("system", { topLevel: true }).add(
|
||||
HttpApiEndpoint.make("GET")("status", "/status", { success: EndpointStatusSuccess }),
|
||||
export const Group2 = HttpApiGroup.make("system", { topLevel: true }).add(
|
||||
HttpApiEndpoint.make("GET")("status", "/status", { success: Endpoint0Success }),
|
||||
)
|
||||
|
||||
type RawGroup = HttpApiClient.Client<typeof GroupSystem>
|
||||
type RawGroup = HttpApiClient.Client<typeof Group2>
|
||||
|
||||
const EndpointStatusDeclaredError = Schema.Never
|
||||
const mapEndpointStatusError = (error: unknown) =>
|
||||
const Endpoint0DeclaredError = Schema.Never
|
||||
const mapEndpoint0Error = (error: unknown) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: Schema.is(EndpointStatusDeclaredError)(error)
|
||||
: Schema.is(Endpoint0DeclaredError)(error)
|
||||
? error
|
||||
: new ClientError({ cause: error })
|
||||
const EndpointStatus = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpointStatusError))
|
||||
const Endpoint0 = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpoint0Error))
|
||||
|
||||
export const adaptGroupSystem = (raw: RawGroup) => ({ status: EndpointStatus(raw) })
|
||||
export const adaptGroup2 = (raw: RawGroup) => ({ status: Endpoint0(raw) })
|
||||
|
||||
+4852
-5008
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,6 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { format } from "prettier"
|
||||
import { fileURLToPath } from "url"
|
||||
import { ClientApi } from "../src/client.js"
|
||||
import { stabilizeOpenApi } from "./openapi-stabilize.js"
|
||||
|
||||
const document = await format(JSON.stringify(stabilizeOpenApi(OpenApi.fromApi(ClientApi)), null, 2), {
|
||||
parser: "json",
|
||||
printWidth: 120,
|
||||
})
|
||||
const document = JSON.stringify(OpenApi.fromApi(ClientApi), null, 2) + "\n"
|
||||
const target = fileURLToPath(new URL("../openapi.json", import.meta.url))
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// Effect gives shared anonymous schemas encounter-order names (`Union_3`). Replace those names
|
||||
// with a hash of their canonical shape and sort components so unrelated additions stay local.
|
||||
export function stabilizeOpenApi(source: object) {
|
||||
const document = source as {
|
||||
components: { schemas: Record<string, unknown> }
|
||||
}
|
||||
const schemas = document.components.schemas
|
||||
const reference = (name: string) => `#/components/schemas/${name}`
|
||||
const families = ["Union", "Objects", "Arrays"].filter((name) => `${name}_` in schemas)
|
||||
const anonymous = new Map(
|
||||
Object.keys(schemas)
|
||||
.filter((name) => families.some((family) => new RegExp(`^${family}_\\d*$`).test(name)))
|
||||
.map((name) => [name, schemas[name]]),
|
||||
)
|
||||
|
||||
const canonical = (node: unknown, seen = new Set<string>()): unknown => {
|
||||
if (Array.isArray(node)) return node.map((item) => canonical(item, seen))
|
||||
if (typeof node !== "object" || node === null) return node
|
||||
const $ref = "$ref" in node && typeof node.$ref === "string" ? node.$ref : undefined
|
||||
const name = $ref?.startsWith(reference("")) ? $ref.slice(reference("").length) : undefined
|
||||
if (name !== undefined && anonymous.has(name)) {
|
||||
if (seen.has(name)) throw new Error(`Recursive anonymous OpenAPI component: ${name}`)
|
||||
return Object.fromEntries([
|
||||
["$ref", canonical(anonymous.get(name), new Set([...seen, name]))],
|
||||
...Object.entries(node)
|
||||
.filter(([key]) => key !== "$ref")
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([key, value]) => [key, canonical(value, seen)]),
|
||||
])
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(node)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([key, value]) => [key, canonical(value, seen)]),
|
||||
)
|
||||
}
|
||||
const renames = new Map(
|
||||
[...anonymous].map(([name, schema]) => {
|
||||
const hash = new Bun.CryptoHasher("sha256")
|
||||
.update(JSON.stringify(canonical(schema)))
|
||||
.digest("hex")
|
||||
.slice(0, 12)
|
||||
return [name, `${name.replace(/_\d*$/, "")}_${hash}`] as const
|
||||
}),
|
||||
)
|
||||
const rewrite = (node: unknown): unknown => {
|
||||
if (Array.isArray(node)) return node.map(rewrite)
|
||||
if (typeof node !== "object" || node === null) return node
|
||||
return Object.fromEntries(
|
||||
Object.entries(node).map(([key, value]) => {
|
||||
if (key !== "$ref" || typeof value !== "string" || !value.startsWith(reference(""))) {
|
||||
return [key, rewrite(value)]
|
||||
}
|
||||
const name = value.slice(reference("").length)
|
||||
return [key, reference(renames.get(name) ?? name)]
|
||||
}),
|
||||
)
|
||||
}
|
||||
const result = rewrite(document) as typeof document
|
||||
const stable = new Map<string, unknown>()
|
||||
for (const [name, schema] of Object.entries(result.components.schemas)) {
|
||||
const target = renames.get(name) ?? name
|
||||
const previous = stable.get(target)
|
||||
if (previous !== undefined && JSON.stringify(canonical(previous)) !== JSON.stringify(canonical(schema))) {
|
||||
throw new Error(`Content-addressed OpenAPI component collision: ${target}`)
|
||||
}
|
||||
stable.set(target, schema)
|
||||
}
|
||||
result.components.schemas = Object.fromEntries(
|
||||
[...stable].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
|
||||
)
|
||||
return result
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
||||
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
||||
@@ -708,20 +708,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.view", "/api/session/:sessionID/view", {
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({ idle: NonNegativeInt }),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: SessionNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.view",
|
||||
summary: "View session",
|
||||
description: "Mark the idle transition observed by the viewer as viewed.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "session",
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { stabilizeOpenApi } from "../script/openapi-stabilize.js"
|
||||
|
||||
test("content-addresses anonymous components without changing reference siblings", () => {
|
||||
const result = stabilizeOpenApi({
|
||||
components: {
|
||||
schemas: {
|
||||
Union_: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
Union_2: { type: "number" },
|
||||
OAuth_2: { type: "string" },
|
||||
},
|
||||
},
|
||||
paths: {
|
||||
"/test": {
|
||||
schema: { $ref: "#/components/schemas/Union_", description: "nullable value" },
|
||||
},
|
||||
},
|
||||
}) as {
|
||||
components: { schemas: Record<string, unknown> }
|
||||
paths: { "/test": { schema: { $ref: string; description: string } } }
|
||||
}
|
||||
|
||||
expect(result.paths["/test"].schema.description).toBe("nullable value")
|
||||
expect(result.paths["/test"].schema.$ref).toMatch(/^#\/components\/schemas\/Union_[a-f0-9]{12}$/)
|
||||
expect(result.components.schemas.OAuth_2).toEqual({ type: "string" })
|
||||
})
|
||||
|
||||
test("keeps anonymous names stable across encounter order and nested ordinals", () => {
|
||||
const generate = (nested: string, parent: string) =>
|
||||
stabilizeOpenApi({
|
||||
components: {
|
||||
schemas: {
|
||||
Union_: { type: "boolean" },
|
||||
Arrays_: { type: "array", items: { type: "boolean" } },
|
||||
[parent]: { type: "array", items: { $ref: `#/components/schemas/${nested}` } },
|
||||
[nested]: { type: "string" },
|
||||
},
|
||||
},
|
||||
}) as { components: { schemas: Record<string, unknown> } }
|
||||
|
||||
expect(generate("Union_1", "Arrays_2")).toEqual(generate("Union_9", "Arrays_7"))
|
||||
})
|
||||
|
||||
test("merges structurally identical anonymous components", () => {
|
||||
const result = stabilizeOpenApi({
|
||||
components: {
|
||||
schemas: {
|
||||
Union_: { type: "string" },
|
||||
Union_2: { type: "string" },
|
||||
},
|
||||
},
|
||||
}) as { components: { schemas: Record<string, unknown> } }
|
||||
|
||||
expect(Object.keys(result.components.schemas)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("includes reference siblings in anonymous component hashes", () => {
|
||||
const result = stabilizeOpenApi({
|
||||
components: {
|
||||
schemas: {
|
||||
Union_: { type: "string" },
|
||||
Arrays_: { type: "array", items: { $ref: "#/components/schemas/Union_", description: "first" } },
|
||||
Arrays_2: { type: "array", items: { $ref: "#/components/schemas/Union_", description: "second" } },
|
||||
},
|
||||
},
|
||||
}) as { components: { schemas: Record<string, unknown> } }
|
||||
|
||||
expect(Object.keys(result.components.schemas).filter((name) => name.startsWith("Arrays_"))).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("preserves authored synthetic-looking names without an anonymous family root", () => {
|
||||
const result = stabilizeOpenApi({
|
||||
components: { schemas: { Union_2: { type: "string" } } },
|
||||
}) as { components: { schemas: Record<string, unknown> } }
|
||||
|
||||
expect(result.components.schemas.Union_2).toEqual({ type: "string" })
|
||||
})
|
||||
@@ -19,14 +19,6 @@ export const Info = Schema.Struct({
|
||||
}).annotate({ identifier: "InstructionEntry.Info" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const Snapshot = Schema.Array(
|
||||
Schema.Struct({
|
||||
...Info.fields,
|
||||
removed: Schema.Boolean,
|
||||
}),
|
||||
).annotate({ identifier: "InstructionEntry.Snapshot" })
|
||||
export type Snapshot = typeof Snapshot.Type
|
||||
|
||||
export const MaxValueBytes = 8 * 1024
|
||||
|
||||
export class ValueTooLargeError extends Schema.TaggedError<ValueTooLargeError>()(
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Revert } from "./session-revert.js"
|
||||
import { Shell as ShellSchema } from "./shell.js"
|
||||
import { SessionError } from "./session-error.js"
|
||||
import { Instruction } from "./instruction.js"
|
||||
import { InstructionEntry } from "./instruction-entry.js"
|
||||
import { Agent } from "./agent.js"
|
||||
import { Skill as SkillSchema } from "./skill.js"
|
||||
import { Money } from "./money.js"
|
||||
@@ -106,17 +105,6 @@ export const Renamed = Event.durable({
|
||||
})
|
||||
export type Renamed = typeof Renamed.Type
|
||||
|
||||
export const Viewed = Event.durable({
|
||||
type: "session.viewed",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
/** Epoch-millisecond idle watermark the viewer observed; projection never marks a newer idle transition viewed. */
|
||||
idle: Schema.Finite,
|
||||
},
|
||||
})
|
||||
export type Viewed = typeof Viewed.Type
|
||||
|
||||
export const UsageRecorded = Event.durable({
|
||||
type: "session.usage.recorded",
|
||||
...options,
|
||||
@@ -160,7 +148,6 @@ export const Forked = Event.durable({
|
||||
parentID: SessionID,
|
||||
boundary: SessionFork.Boundary,
|
||||
instructions: Instruction.Values.pipe(optional),
|
||||
instructionEntries: InstructionEntry.Snapshot.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type Forked = typeof Forked.Type
|
||||
@@ -598,7 +585,6 @@ export const Definitions = Event.inventory(
|
||||
ModelSelected,
|
||||
Moved,
|
||||
Renamed,
|
||||
Viewed,
|
||||
UsageUpdated,
|
||||
Deleted,
|
||||
Forked,
|
||||
|
||||
@@ -34,8 +34,6 @@ export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export const Status = Event.ephemeral({
|
||||
type: "session.status",
|
||||
// The bare SessionStatus identifier belongs to the status union above.
|
||||
identifier: "SessionStatusUpdated",
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
status: Info,
|
||||
|
||||
@@ -37,13 +37,9 @@ export const Info = Schema.Struct({
|
||||
model: Model.Ref.pipe(optional),
|
||||
cost: Money.USD,
|
||||
tokens: TokenUsage.Info,
|
||||
/** Outcome of the last completed execution, recorded at `time.idle`. Absent until a run reaches a terminal transition. */
|
||||
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]).pipe(optional),
|
||||
time: Schema.Struct({
|
||||
created: DateTimeUtcFromMillis,
|
||||
updated: DateTimeUtcFromMillis,
|
||||
idle: DateTimeUtcFromMillis.pipe(optional),
|
||||
viewed: DateTimeUtcFromMillis.pipe(optional),
|
||||
archived: DateTimeUtcFromMillis.pipe(optional),
|
||||
}),
|
||||
title: Schema.String.pipe(optional),
|
||||
|
||||
@@ -54,29 +54,17 @@ describe("contract hygiene", () => {
|
||||
}),
|
||||
).toEqual({ text: "completed" })
|
||||
|
||||
const info = Session.Info.make({
|
||||
id: Session.ID.make("ses_untitled"),
|
||||
projectID: Project.ID.make("global"),
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(0),
|
||||
updated: DateTime.makeUnsafe(0),
|
||||
idle: undefined,
|
||||
viewed: undefined,
|
||||
},
|
||||
title: undefined,
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
})
|
||||
const encoded = Schema.encodeSync(Session.Info)(info)
|
||||
expect(encoded).not.toHaveProperty("title")
|
||||
expect(encoded.time).toEqual({ created: 0, updated: 0 })
|
||||
expect(
|
||||
Schema.encodeSync(Session.Info)({
|
||||
...info,
|
||||
time: { ...info.time, idle: DateTime.makeUnsafe(2), viewed: DateTime.makeUnsafe(1) },
|
||||
}).time,
|
||||
).toEqual({ created: 0, updated: 0, idle: 2, viewed: 1 })
|
||||
id: Session.ID.make("ses_untitled"),
|
||||
projectID: Project.ID.make("global"),
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
title: undefined,
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
}),
|
||||
).not.toHaveProperty("title")
|
||||
})
|
||||
|
||||
test("session inbox items omit the internal enqueue sequence", () => {
|
||||
|
||||
@@ -83,7 +83,6 @@ describe("public event manifest", () => {
|
||||
"session.model.selected.1",
|
||||
"session.moved.1",
|
||||
"session.renamed.1",
|
||||
"session.viewed.1",
|
||||
"session.usage.recorded.1",
|
||||
"session.forked.2",
|
||||
"session.inbox.delivered.1",
|
||||
|
||||
@@ -156,15 +156,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.view",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session
|
||||
.view({ sessionID: ctx.params.sessionID, idle: ctx.payload.idle })
|
||||
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.remove",
|
||||
Effect.fn(function* (ctx) {
|
||||
@@ -249,9 +240,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
Effect.catchTag("Session.DestinationNotDirectoryError", (error) =>
|
||||
Effect.fail(new InvalidRequestError({ message: `Not a directory: ${error.directory}` })),
|
||||
),
|
||||
Effect.catchTag("Session.DestinationUnavailableError", (error) =>
|
||||
Effect.fail(new InvalidRequestError({ message: `Directory is unavailable: ${error.directory}` })),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
|
||||
@@ -52,53 +52,6 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("serves the session view operation and missing-session error", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
const created = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/session", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{}",
|
||||
}),
|
||||
).then((response) => response.json()),
|
||||
)
|
||||
if (typeof created !== "object" || created === null || !("data" in created))
|
||||
return yield* Effect.die(new Error("Expected a session response"))
|
||||
const data = created.data
|
||||
if (typeof data !== "object" || data === null || !("id" in data) || typeof data.id !== "string")
|
||||
return yield* Effect.die(new Error("Expected a session ID"))
|
||||
|
||||
const viewed = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request(`http://opencode.local/api/session/${data.id}/view`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ idle: 0 }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(viewed.status).toBe(204)
|
||||
|
||||
const invalid = yield* Effect.promise(() =>
|
||||
handler(new Request(`http://opencode.local/api/session/${data.id}/view`, { method: "POST" })),
|
||||
)
|
||||
expect(invalid.status).toBe(400)
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/session/ses_missing_view/view", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ idle: 0 }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(missing.status).toBe(404)
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
|
||||
// an aborted first request cannot interrupt layer construction and wedge every later request
|
||||
// (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/shell-scan",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/anomalyco/opencode.git",
|
||||
"directory": "packages/shell-scan"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"test": "bun test --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
await rm("dist", { recursive: true, force: true })
|
||||
await $`bun tsc -p tsconfig.build.json`
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
const dryRun = Bun.argv.includes("--dry-run")
|
||||
const originalText = await Bun.file("package.json").text()
|
||||
const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
|
||||
if (!dryRun && (await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
|
||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
await $`bun run typecheck`
|
||||
await $`bun run build`
|
||||
pkg.exports = Object.fromEntries(
|
||||
Object.entries(pkg.exports).map(([key, value]) => {
|
||||
if (typeof value !== "string") return [key, value]
|
||||
return [
|
||||
key,
|
||||
{
|
||||
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
|
||||
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
if (!dryRun) await $`npm publish ${tarball} --tag ${Script.channel} --access public`
|
||||
} finally {
|
||||
await Bun.write("package.json", originalText)
|
||||
await rm(tarball, { force: true })
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ShellScan from "./scan.js"
|
||||
export * as ShellScan from "./index.js"
|
||||
|
||||
export type OpaqueReason =
|
||||
| "command-substitution"
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
import { ShellScan } from "../src/index.js"
|
||||
|
||||
describe("ShellScan adversarial corpus", () => {
|
||||
test.each([
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
import { ShellScan } from "../src/index.js"
|
||||
|
||||
const opaque = ["$COMMAND hidden", "$(printf command) hidden", 'printf "unterminated'] as const
|
||||
const contexts = [
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
import { ShellScan } from "../src/index.js"
|
||||
|
||||
const staticCommands = [
|
||||
["git status", ["git", "status"]],
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
import { ShellScan } from "../src/index.js"
|
||||
|
||||
describe("ShellScan structural mutation closure", () => {
|
||||
test.each([
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
import { ShellScan } from "../src/index.js"
|
||||
|
||||
describe("ShellScan", () => {
|
||||
test("scans a static command", () => {
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": false,
|
||||
"allowJs": false,
|
||||
"noUncheckedIndexedAccess": false
|
||||
},
|
||||
"include": ["src", "test", "bench", "research"]
|
||||
}
|
||||
@@ -33,23 +33,14 @@ function Status(props: { status: McpServer["status"]; loading: boolean }) {
|
||||
return <>Disabled ○</>
|
||||
}
|
||||
|
||||
export function DialogMcp(props: { initialServer?: string; details?: boolean } = {}) {
|
||||
export function DialogMcp() {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const servers = createMemo(() =>
|
||||
pipe(
|
||||
data.location.mcp.server.list() ?? [],
|
||||
sortBy((server) => server.name),
|
||||
),
|
||||
)
|
||||
const initial = props.initialServer ? servers().find((server) => server.name === props.initialServer) : undefined
|
||||
const [focused, setFocused] = createSignal<string | undefined>(props.initialServer)
|
||||
const [detail, setDetail] = createSignal<McpServer | undefined>(
|
||||
props.details && initial?.status.status === "failed" ? initial : undefined,
|
||||
)
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const [detail, setDetail] = createSignal<McpServer>()
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
|
||||
const statusColor = (status: McpServer["status"]) => {
|
||||
@@ -59,6 +50,13 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } =
|
||||
return theme.text.subdued
|
||||
}
|
||||
|
||||
const servers = createMemo(() =>
|
||||
pipe(
|
||||
data.location.mcp.server.list() ?? [],
|
||||
sortBy((server) => server.name),
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (focused()) return
|
||||
const first = servers()[0]
|
||||
@@ -155,7 +153,7 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } =
|
||||
title={`MCP server: ${server().name}`}
|
||||
error={statusError(server().status) ?? "Unknown MCP connection error"}
|
||||
onBack={() => {
|
||||
setDetail(undefined)
|
||||
setDetail()
|
||||
dialog.setSize("medium")
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
@@ -25,12 +25,12 @@ import {
|
||||
type ClosedSessionTab,
|
||||
type SessionTab,
|
||||
type SessionTabHistory,
|
||||
type SessionTabUnread,
|
||||
} from "./session-tabs-model"
|
||||
|
||||
type TabsState = {
|
||||
tabs: SessionTab[]
|
||||
// Read only long enough to remove the former client-owned state from persisted tab files.
|
||||
unread?: Record<string, unknown>
|
||||
unread: Record<string, SessionTabUnread>
|
||||
}
|
||||
|
||||
type PersistedState = {
|
||||
@@ -43,12 +43,10 @@ type ScrollAnchor = {
|
||||
screenY: number
|
||||
}
|
||||
|
||||
const empty = (): TabsState => ({ tabs: [] })
|
||||
const empty = (): TabsState => ({ tabs: [], unread: {} })
|
||||
|
||||
// Deliberately after connect settles: the visible session's mount syncs win the first slots.
|
||||
const TAB_PREFETCH_DELAY = 300
|
||||
const VIEW_RETRY_DELAY = 250
|
||||
const VIEW_RETRY_MAX_DELAY = 5_000
|
||||
|
||||
export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({
|
||||
name: "SessionTabs",
|
||||
@@ -62,7 +60,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const paths = useTuiPaths()
|
||||
const renderer = useRenderer()
|
||||
const enabled = () => config.tabs.enabled
|
||||
const [focused, setFocused] = createSignal<boolean>()
|
||||
// Focus reporting emits transitions, so an interactive launch owns unread state until its first blur.
|
||||
const [focused, setFocused] = createSignal(true)
|
||||
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
|
||||
// mutating in place, which per-row animations and drag state depend on.
|
||||
const [store, updateStore] = useStorage().store<PersistedState>("tabs", {
|
||||
@@ -88,7 +87,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
|
||||
const onFocus = () => setFocused(true)
|
||||
const onBlur = () => setFocused(false)
|
||||
useKeyboard(onFocus)
|
||||
renderer.on("focus", onFocus)
|
||||
renderer.on("blur", onBlur)
|
||||
onCleanup(() => {
|
||||
@@ -114,20 +112,16 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const session = data.session.get(sessionID)
|
||||
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
|
||||
}
|
||||
const isUnread = (sessionID: string) => {
|
||||
const info = data.session.get(sessionID)
|
||||
return info?.time.idle !== undefined && (info.time.viewed === undefined || info.time.idle > info.time.viewed)
|
||||
}
|
||||
const family = (sessionID: string) => {
|
||||
const session = root(sessionID)
|
||||
const members = data.session.family(session)
|
||||
return members.length > 0 ? members : [session]
|
||||
}
|
||||
const normalize = (value: TabsState) => ({
|
||||
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
|
||||
const sessionID = root(tab.sessionID)
|
||||
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
|
||||
}, []),
|
||||
unread: Object.entries(value.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
||||
const sessionID = root(entry[0])
|
||||
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
|
||||
return result
|
||||
}, {}),
|
||||
})
|
||||
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
|
||||
const newTab = createMemo((open = false) => {
|
||||
@@ -138,23 +132,29 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
}, false)
|
||||
const status = (sessionID: string) => {
|
||||
const session = root(sessionID)
|
||||
const members = family(session)
|
||||
const members = data.session.family(session)
|
||||
const family = members.length > 0 ? members : [session]
|
||||
return {
|
||||
// Unread reads the root session only: background subagent completions wake the parent,
|
||||
// whose own idle transition then carries the signal.
|
||||
unread: !isUnread(session)
|
||||
? undefined
|
||||
: data.session.get(session)?.outcome === "failed"
|
||||
? ("error" as const)
|
||||
: ("activity" as const),
|
||||
unread: state().unread[session],
|
||||
promptPulse: promptPulses()[session] ?? 0,
|
||||
attention: members.some(
|
||||
attention: family.some(
|
||||
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
|
||||
),
|
||||
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
}
|
||||
}
|
||||
|
||||
function markUnread(sessionID: string, unread: SessionTabUnread) {
|
||||
if (!enabled() || !focused()) return
|
||||
const session = root(sessionID)
|
||||
if (current() === session || !state().tabs.some((tab) => tab.sessionID === session)) return
|
||||
if (state().unread[session] === unread) return
|
||||
update((draft) => {
|
||||
if (!draft.tabs.some((tab) => tab.sessionID === session)) return
|
||||
draft.unread[session] = unread
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) return
|
||||
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
|
||||
@@ -176,40 +176,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
})
|
||||
})
|
||||
|
||||
// Viewed state is server-global, so acknowledgement runs even with tabs disabled: other
|
||||
// clients rely on this client reporting what its user has seen.
|
||||
const acknowledged = new Map<string, number>()
|
||||
const [viewRetry, setViewRetry] = createSignal(0)
|
||||
let viewRetryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let viewRetryAttempt = 0
|
||||
onCleanup(() => clearTimeout(viewRetryTimer))
|
||||
createEffect(() => {
|
||||
viewRetry()
|
||||
if (focused() !== true) return
|
||||
if (!enabled() || !focused()) return
|
||||
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
|
||||
const sessionID = root(route.data.sessionID)
|
||||
const idle = data.session.get(sessionID)?.time.idle
|
||||
if (idle === undefined || !isUnread(sessionID) || acknowledged.get(sessionID) === idle) return
|
||||
// Record before the request so event-driven re-runs don't re-post the same watermark.
|
||||
acknowledged.set(sessionID, idle)
|
||||
void client.api.session.view({ sessionID, idle }).then(
|
||||
() => {
|
||||
clearTimeout(viewRetryTimer)
|
||||
viewRetryTimer = undefined
|
||||
viewRetryAttempt = 0
|
||||
},
|
||||
() => {
|
||||
if (acknowledged.get(sessionID) !== idle) return
|
||||
acknowledged.delete(sessionID)
|
||||
if (viewRetryTimer) return
|
||||
const delay = Math.min(VIEW_RETRY_DELAY * 2 ** viewRetryAttempt, VIEW_RETRY_MAX_DELAY)
|
||||
viewRetryAttempt++
|
||||
viewRetryTimer = setTimeout(() => {
|
||||
viewRetryTimer = undefined
|
||||
setViewRetry((value) => value + 1)
|
||||
}, delay)
|
||||
},
|
||||
)
|
||||
if (!state().unread[sessionID]) return
|
||||
update((draft) => {
|
||||
delete draft.unread[sessionID]
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -219,7 +193,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
update((draft) => {
|
||||
const next = normalize(draft)
|
||||
draft.tabs = next.tabs
|
||||
delete draft.unread
|
||||
draft.unread = next.unread
|
||||
})
|
||||
})
|
||||
|
||||
@@ -240,7 +214,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const sessionIDs = signature.split("\n")
|
||||
let stale = false
|
||||
void (async () => {
|
||||
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID, { children: true })))
|
||||
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID)))
|
||||
if (stale) return
|
||||
const locations = new Map(
|
||||
sessionIDs
|
||||
@@ -274,6 +248,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
|
||||
onCleanup(
|
||||
event.on("session.moved", (evt) => {
|
||||
if (!enabled() || !state().tabs.some((tab) => tab.sessionID === root(evt.data.sessionID))) return
|
||||
@@ -310,6 +287,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
history = previous.history
|
||||
update((draft) => {
|
||||
draft.tabs = closeSessionTab(draft.tabs, target).tabs
|
||||
delete draft.unread[target]
|
||||
})
|
||||
setPromptPulses((pulses) => {
|
||||
if (pulses[target] === undefined) return pulses
|
||||
@@ -406,7 +384,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
cycleUnread(direction: 1 | -1) {
|
||||
if (!enabled()) return
|
||||
const tab = cycleSessionTab(state().tabs, current(), direction, (tab) =>
|
||||
Boolean(status(tab.sessionID).unread || status(tab.sessionID).attention),
|
||||
Boolean(state().unread[tab.sessionID] || status(tab.sessionID).attention),
|
||||
)
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
|
||||
import { DialogMcp } from "../../component/dialog-mcp"
|
||||
|
||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
const [open, setOpen] = createSignal(true)
|
||||
@@ -40,16 +39,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
<Show when={list().length <= 2 || open()}>
|
||||
<For each={list()}>
|
||||
{(item) => (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
minWidth={0}
|
||||
onMouseUp={() =>
|
||||
props.context.ui.dialog.show(() => (
|
||||
<DialogMcp initialServer={item.name} details={item.status.status === "failed"} />
|
||||
))
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
@@ -58,21 +48,18 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.text.default} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<b>{item.name}</b>
|
||||
</text>
|
||||
<text
|
||||
fg={item.status.status === "failed" ? theme.text.feedback.error.default : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Switch fallback={item.status.status}>
|
||||
<Match when={item.status.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status.status === "pending"}>Connecting</Match>
|
||||
<Match when={item.status.status === "failed"}>Error</Match>
|
||||
<Match when={item.status.status === "disabled"}>Disabled</Match>
|
||||
<Match when={item.status.status === "needs_auth"}>Sign in</Match>
|
||||
</Switch>
|
||||
<text fg={theme.text.default} wrapMode="word">
|
||||
{item.name}{" "}
|
||||
<span style={{ fg: theme.text.subdued }}>
|
||||
<Switch fallback={item.status.status}>
|
||||
<Match when={item.status.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status.status === "failed"}>
|
||||
<i>{item.status.status === "failed" ? item.status.error : undefined}</i>
|
||||
</Match>
|
||||
<Match when={item.status.status === "disabled"}>Disabled</Match>
|
||||
<Match when={item.status.status === "needs_auth"}>Needs auth</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
@@ -34,9 +34,6 @@ test("scopes sessions to the active session location", async () => {
|
||||
return json({ directory, project: { id: project, directory, canonical: directory } })
|
||||
}
|
||||
if (url.pathname !== "/api/session") return undefined
|
||||
// Family syncs list children by parentID; only project-scoped list requests matter here.
|
||||
const parentID = url.searchParams.get("parentID")
|
||||
if (parentID && parentID !== "null") return json({ data: [], cursor: {} })
|
||||
const project = url.searchParams.get("project") ?? ""
|
||||
requestedProjects.push(project)
|
||||
return json({
|
||||
|
||||
@@ -35,12 +35,7 @@ async function renderSessionTabs(
|
||||
persisted?: string[]
|
||||
sessionGate?: Promise<void>
|
||||
sessionDirectories?: Record<string, string>
|
||||
sessionParents?: Record<string, string>
|
||||
sessionTimes?: Record<string, { idle?: number; viewed?: number }>
|
||||
sessionOutcomes?: Record<string, "succeeded" | "failed" | "interrupted">
|
||||
newLocation?: "launch" | "inherit"
|
||||
tabsEnabled?: boolean
|
||||
viewFailures?: number
|
||||
},
|
||||
) {
|
||||
const temporary = options?.state ? undefined : await tmpdir()
|
||||
@@ -51,26 +46,16 @@ async function renderSessionTabs(
|
||||
await Bun.write(
|
||||
file,
|
||||
JSON.stringify({
|
||||
global: { tabs: [], unread: { ses_legacy: "error" } },
|
||||
cwd: {
|
||||
[directory]: {
|
||||
tabs: options.persisted.map((sessionID) => ({ sessionID })),
|
||||
unread: { ses_legacy: "activity" },
|
||||
},
|
||||
},
|
||||
global: { tabs: [], unread: {} },
|
||||
cwd: { [directory]: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} } },
|
||||
}),
|
||||
)
|
||||
}
|
||||
const events = createEventStream()
|
||||
const sessions: string[] = []
|
||||
const views: string[] = []
|
||||
const viewWatermarks: number[] = []
|
||||
const locations: string[] = []
|
||||
const vcsLocations: string[] = []
|
||||
const sessionTimes = Object.fromEntries(
|
||||
Object.entries(options?.sessionTimes ?? {}).map(([sessionID, time]) => [sessionID, { ...time }]),
|
||||
)
|
||||
const calls = createFetch(async (url, request) => {
|
||||
const calls = createFetch(async (url) => {
|
||||
if (url.pathname === "/api/location") {
|
||||
const requested = url.searchParams.get("location[directory]") ?? directory
|
||||
locations.push(requested)
|
||||
@@ -87,45 +72,22 @@ async function renderSessionTabs(
|
||||
data: { branch: { current: "main", default: "main" } },
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/session" && url.searchParams.has("parentID")) {
|
||||
const parentID = url.searchParams.get("parentID")
|
||||
const children = Object.entries(options?.sessionParents ?? {})
|
||||
.filter(([, parent]) => parent === parentID)
|
||||
.map(([sessionID]) => sessionInfo(sessionID))
|
||||
return json({ data: children, cursor: {} })
|
||||
}
|
||||
const viewed = url.pathname.match(/^\/api\/session\/([^/]+)\/view$/)?.[1]
|
||||
if (viewed && request.method === "POST") {
|
||||
views.push(viewed)
|
||||
const payload: unknown = await request.json()
|
||||
if (typeof payload !== "object" || payload === null || !("idle" in payload) || typeof payload.idle !== "number")
|
||||
throw new Error("Expected an idle watermark")
|
||||
viewWatermarks.push(payload.idle)
|
||||
if (views.length <= (options?.viewFailures ?? 0)) return new Response(null, { status: 503 })
|
||||
const time = (sessionTimes[viewed] ??= {})
|
||||
time.viewed = Math.min(payload.idle, time.idle ?? payload.idle)
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
|
||||
if (!sessionID) return undefined
|
||||
sessions.push(sessionID)
|
||||
await options?.sessionGate
|
||||
return json({ data: sessionInfo(sessionID) })
|
||||
return json({
|
||||
data: {
|
||||
id: sessionID,
|
||||
title: sessionID === initialSessionID ? options?.title : undefined,
|
||||
projectID: "project",
|
||||
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
},
|
||||
})
|
||||
}, events)
|
||||
|
||||
function sessionInfo(sessionID: string) {
|
||||
return {
|
||||
id: sessionID,
|
||||
parentID: options?.sessionParents?.[sessionID],
|
||||
title: sessionID === initialSessionID ? options?.title : undefined,
|
||||
projectID: "project",
|
||||
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
outcome: options?.sessionOutcomes?.[sessionID],
|
||||
time: { created: 0, updated: 0, ...sessionTimes[sessionID] },
|
||||
}
|
||||
}
|
||||
let tabs!: ReturnType<typeof useSessionTabs>
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
@@ -147,7 +109,7 @@ async function renderSessionTabs(
|
||||
<StorageProvider>
|
||||
<ConfigProvider
|
||||
config={createTuiResolvedConfig({
|
||||
tabs: { enabled: options?.tabsEnabled ?? true },
|
||||
tabs: { enabled: true },
|
||||
session: { new_location: options?.newLocation ?? "launch" },
|
||||
})}
|
||||
>
|
||||
@@ -176,14 +138,9 @@ async function renderSessionTabs(
|
||||
route,
|
||||
data,
|
||||
sessions,
|
||||
views,
|
||||
viewWatermarks,
|
||||
locations,
|
||||
vcsLocations,
|
||||
state,
|
||||
setSessionTime(sessionID: string, time: { idle?: number; viewed?: number }) {
|
||||
sessionTimes[sessionID] = time
|
||||
},
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
focus: () => app.renderer.emit("focus"),
|
||||
blur: () => app.renderer.emit("blur"),
|
||||
@@ -196,6 +153,14 @@ async function renderSessionTabs(
|
||||
}
|
||||
}
|
||||
|
||||
const executionSucceeded = (sessionID: string): OpenCodeEvent => ({
|
||||
id: `evt_done_${sessionID}`,
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID },
|
||||
})
|
||||
|
||||
test("loads persisted tab metadata concurrently on connect", async () => {
|
||||
let release!: () => void
|
||||
const sessionGate = new Promise<void>((resolve) => (release = resolve))
|
||||
@@ -265,10 +230,10 @@ test("stores session tabs for the current working directory by default", async (
|
||||
const file = path.join(setup.state, "test", "tui", "tabs.json")
|
||||
await wait(() => Bun.file(file).size > 0)
|
||||
const stored = await Bun.file(file).json()
|
||||
expect(stored.global).toEqual({ tabs: [] })
|
||||
expect(stored.global).toEqual({ tabs: [], unread: {} })
|
||||
expect(Object.keys(stored.cwd)).toEqual([directory])
|
||||
expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"])
|
||||
expect(stored.cwd[directory]).not.toHaveProperty("unread")
|
||||
expect(stored.cwd[directory].unread).toEqual({})
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
@@ -292,172 +257,47 @@ test("keeps scroll anchors for open session tabs", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("derives unread state from server session times", async () => {
|
||||
const setup = await renderSessionTabs("first", {
|
||||
home: true,
|
||||
persisted: ["first", "second"],
|
||||
sessionTimes: { second: { idle: 2 } },
|
||||
})
|
||||
test("only the foreground TUI mutates unread state", async () => {
|
||||
await using temporary = await tmpdir()
|
||||
let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
let background: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
|
||||
try {
|
||||
await wait(() => setup.tabs.status("second").unread === "activity")
|
||||
expect(setup.tabs.status("first").unread).toBeUndefined()
|
||||
foreground = await renderSessionTabs("first", { state: temporary.path, persisted: ["first", "second"] })
|
||||
background = await renderSessionTabs("second", { state: temporary.path })
|
||||
foreground.focus()
|
||||
background.blur()
|
||||
await wait(() => foreground?.tabs.tabs().length === 2 && background?.tabs.tabs().length === 2, 2_000, "shared tabs")
|
||||
|
||||
const firstDone = executionSucceeded("first")
|
||||
foreground.emit(firstDone)
|
||||
background.emit(firstDone)
|
||||
await Promise.all([foreground.flush(), background.flush()])
|
||||
expect(foreground.tabs.status("first").unread).toBeUndefined()
|
||||
expect(background.tabs.status("first").unread).toBeUndefined()
|
||||
|
||||
const secondDone = executionSucceeded("second")
|
||||
foreground.emit(secondDone)
|
||||
background.emit(secondDone)
|
||||
await wait(
|
||||
() =>
|
||||
foreground?.tabs.status("second").unread === "activity" &&
|
||||
background?.tabs.status("second").unread === "activity",
|
||||
10_000,
|
||||
"shared unread activity",
|
||||
)
|
||||
|
||||
foreground.tabs.select("second")
|
||||
await wait(
|
||||
() =>
|
||||
foreground?.tabs.status("second").unread === undefined &&
|
||||
background?.tabs.status("second").unread === undefined,
|
||||
10_000,
|
||||
"shared unread clearing",
|
||||
)
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("marks unread failed sessions with error styling", async () => {
|
||||
const setup = await renderSessionTabs("first", {
|
||||
home: true,
|
||||
persisted: ["first", "second"],
|
||||
sessionTimes: { first: { idle: 2 }, second: { idle: 2 } },
|
||||
sessionOutcomes: { second: "failed" },
|
||||
})
|
||||
try {
|
||||
await wait(() => setup.tabs.status("second").unread === "error")
|
||||
expect(setup.tabs.status("first").unread).toBe("activity")
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("acknowledges viewed sessions even when tabs are disabled", async () => {
|
||||
const setup = await renderSessionTabs("first", {
|
||||
tabsEnabled: false,
|
||||
sessionTimes: { first: { idle: 2 } },
|
||||
})
|
||||
try {
|
||||
setup.focus()
|
||||
await setup.data.session.sync("first")
|
||||
await wait(() => setup.views.includes("first"))
|
||||
expect(setup.tabs.tabs()).toEqual([])
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("purges legacy persisted unread records", async () => {
|
||||
const setup = await renderSessionTabs("first", { persisted: ["first"] })
|
||||
try {
|
||||
const file = path.join(setup.state, "test", "tui", "tabs.json")
|
||||
// Normalize rewrites the active scope; the legacy record must not survive it.
|
||||
await wait(async () => {
|
||||
const stored = await Bun.file(file).json()
|
||||
return !("unread" in stored.cwd[directory])
|
||||
})
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("refreshes server session times after terminal events", async () => {
|
||||
const setup = await renderSessionTabs("first", { home: true, persisted: ["first"] })
|
||||
try {
|
||||
// Terminal events refresh only already-loaded sessions, so ensure the initial sync landed.
|
||||
await wait(() => setup.data.session.get("first") !== undefined)
|
||||
setup.setSessionTime("first", { idle: 2 })
|
||||
setup.emit({
|
||||
id: "evt_done_first",
|
||||
created: 2,
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "first", seq: 1, version: 1 },
|
||||
data: { sessionID: "first" },
|
||||
})
|
||||
await wait(() => setup.tabs.status("first").unread === "activity")
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("views a selected unread session only while focused", async () => {
|
||||
const setup = await renderSessionTabs("first", {
|
||||
home: true,
|
||||
persisted: ["first"],
|
||||
sessionTimes: { first: { idle: 2 } },
|
||||
})
|
||||
try {
|
||||
setup.blur()
|
||||
setup.route.navigate({ type: "session", sessionID: "first" })
|
||||
await wait(() => setup.tabs.current() === "first" && setup.tabs.status("first").unread === "activity")
|
||||
await Bun.sleep(20)
|
||||
expect(setup.views).toEqual([])
|
||||
|
||||
setup.focus()
|
||||
await wait(() => setup.views.includes("first"))
|
||||
setup.emit({
|
||||
id: "evt_viewed_first",
|
||||
created: 3,
|
||||
type: "session.viewed",
|
||||
durable: { aggregateID: "first", seq: 2, version: 1 },
|
||||
data: { sessionID: "first", idle: 2 },
|
||||
})
|
||||
await wait(() => setup.tabs.status("first").unread === undefined)
|
||||
expect(setup.views).toEqual(["first"])
|
||||
expect(setup.viewWatermarks).toEqual([2])
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not acknowledge an unread session until focus is confirmed", async () => {
|
||||
const setup = await renderSessionTabs("first", { sessionTimes: { first: { idle: 2 } } })
|
||||
try {
|
||||
await wait(() => setup.tabs.status("first").unread === "activity")
|
||||
await Bun.sleep(20)
|
||||
expect(setup.views).toEqual([])
|
||||
|
||||
setup.focus()
|
||||
await wait(() => setup.views.includes("first"))
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("retries a failed view acknowledgement", async () => {
|
||||
const setup = await renderSessionTabs("first", {
|
||||
sessionTimes: { first: { idle: 2 } },
|
||||
viewFailures: 1,
|
||||
})
|
||||
try {
|
||||
setup.focus()
|
||||
await wait(() => setup.views.length === 2)
|
||||
expect(setup.views).toEqual(["first", "first"])
|
||||
expect(setup.viewWatermarks).toEqual([2, 2])
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores subagent unread state on the root tab", async () => {
|
||||
const setup = await renderSessionTabs("root", {
|
||||
home: true,
|
||||
persisted: ["root"],
|
||||
sessionParents: { child: "root" },
|
||||
sessionTimes: { child: { idle: 2 } },
|
||||
})
|
||||
try {
|
||||
await wait(() => setup.data.session.get("child") !== undefined)
|
||||
expect(setup.tabs.status("root").unread).toBeUndefined()
|
||||
|
||||
setup.route.navigate({ type: "session", sessionID: "root" })
|
||||
await Bun.sleep(20)
|
||||
expect(setup.views).toEqual([])
|
||||
|
||||
// A background subagent completion wakes the parent; the parent's own idle transition
|
||||
// then carries the unread signal and is the only state acknowledged.
|
||||
setup.focus()
|
||||
setup.setSessionTime("root", { idle: 3 })
|
||||
setup.emit({
|
||||
id: "evt_done_root",
|
||||
created: 3,
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "root", seq: 1, version: 1 },
|
||||
data: { sessionID: "root" },
|
||||
})
|
||||
await wait(() => setup.views.includes("root"))
|
||||
expect(setup.views).toEqual(["root"])
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
if (foreground) await foreground.destroy()
|
||||
if (background) await background.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -36,16 +36,12 @@
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||
grid-area: 1 / 1;
|
||||
white-space: pre;
|
||||
transition: opacity var(--text-shimmer-swap) ease-out;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"]:where([data-active="true"]) [data-slot="text-shimmer-char-base"],
|
||||
[data-component="text-shimmer"]:where([data-active="true"]) [data-slot="text-shimmer-char-shimmer"] {
|
||||
transition: opacity var(--text-shimmer-swap) ease-out;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {
|
||||
color: inherit;
|
||||
opacity: 1;
|
||||
@@ -116,7 +112,6 @@
|
||||
color: inherit;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
background-image: none;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user