Compare commits

..

8 Commits

Author SHA1 Message Date
Brendan Allan 713e6b488f test(app): retain gated legacy projection coverage 2026-08-05 13:36:02 +08:00
Brendan Allan ceeb8f0124 refactor(app): gate unsupported v2 capabilities 2026-08-05 13:36:02 +08:00
Brendan Allan e1d4a8189c test(app): select v2 smoke transport 2026-08-05 13:36:00 +08:00
Brendan Allan f9d62fdc83 test(app): align migrated session projections 2026-08-05 13:36:00 +08:00
Brendan Allan 208bbacf83 test(app): cover migrated v2 operations 2026-08-05 13:36:00 +08:00
Brendan Allan 6093edcc4e refactor(app): migrate supported v2 APIs 2026-08-05 13:35:59 +08:00
Brendan Allan c616de02f3 test(app): update v2 history root fixture 2026-08-05 13:35:49 +08:00
Brendan Allan 5c80086324 fix(app): reconcile v2 session projections 2026-08-05 13:35:49 +08:00
133 changed files with 14094 additions and 4430 deletions
+26 -195
View File
@@ -5,27 +5,8 @@
- Use the `dev` branch database schema and migration registry as the V1 baseline.
- Remove migrations that exist only on the V2 branch.
- Generate one canonical migration from the `dev` schema to the final V2 schema.
- Keep the canonical migration focused on schema changes and dropping obsolete tables.
- Run the V1 history backfill through an experimental server endpoint invoked by the CLI before it opens the TUI.
- Show committed session progress while the endpoint runs.
Expose `GET /api/experimental/migration/v1` for status and a blocking `POST /api/experimental/migration/v1` to run or
resume the backfill. The status is `required`, `running`, or `completed`. On startup, the CLI checks status first and
renders no migration UI when it is already complete. For required or running status, it shows a spinner and waits for the
blocking POST without a request timeout. While migration runs, poll GET once per second and render completed and total
session counts. GET derives total from all session rows and completed from rows through the stored cursor; the count
advances only after a session transaction commits. The POST returns `{ status: "completed" }`. Do not add a background
job or streaming progress protocol. Interrupted calls resume from the stored cursor.
Initially, only interactive TUI startup performs this check; noninteractive run, ACP, raw API, service, health, version,
and help flows do not trigger the backfill.
Keep migration behavior in Core: status, semaphore, checkpointing, V1 decoding, transformation, and database writes.
Protocol owns the experimental GET/POST contracts, Server handlers delegate to Core, and the interactive CLI owns only
the status check and spinner presentation.
Guard the endpoint with one process-local Effect `Semaphore`. Concurrent callers wait; after the active call completes,
waiting callers acquire the permit, observe the completion key, and return immediately. No distributed lock is required
for the current single elected server process.
- Add explicit data operations to that migration where generated DDL is insufficient.
- Test the migration against a populated database at the exact `dev` schema.
## Preserve
@@ -34,35 +15,20 @@ The canonical V1 data remains in its existing tables. In particular, preserve `s
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
workspace relationships.
Preserve existing non-null `session.agent` and `session.model` selections. Fill missing values from the latest ordinary
V1 user message ordered by `time_created` and `id`, excluding compaction and subtask-only messages. Copy agent, provider
ID, model ID, and variant, normalizing an absent variant to `default`.
Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the
generated migration must not drop the table.
Recompute session usage aggregates from all canonical V1 assistant messages, including compaction or other internal
assistants omitted from the V2 projection. Overwrite session cost and input, output, reasoning, cache-read, and
cache-write token totals with those sums.
## Truncate
Clear persisted `session.revert` state. A staged revert is transient operational state and may refer to omitted projection
rows or unavailable snapshots; it must not resume automatically after upgrading. Preserve the underlying messages,
parts, and file history.
Truncate these pre-launch V2 tables before applying schema changes:
Clear `session.time_compacting`, leave the new `time_suspended` column as `NULL`, and preserve session creation, update,
and archive timestamps. Preserve project `time_initialized`; it is unrelated durable state.
- `event`
- `event_sequence`
- `session_message`
Keep the legacy `todo` table and its data physically unchanged, but do not include it in the final V2 Drizzle schema.
After generation, remove the generated `DROP TABLE todo` statement from the canonical migration so the table remains as
unmanaged legacy storage.
## Per-Session Replacement
Do not truncate `event`, `event_sequence`, or `session_message` globally before the backfill. A whole-table delete can
hold SQLite's writer lock long enough to block the running TUI.
Replace each legacy session's V2 state inside that session's checkpointed migration transaction. Delete `event` rows for
the session aggregate, delete its `session_message` rows, rebuild its projection from canonical V1 `message` and `part`
rows, and overwrite its `event_sequence` watermark. If migration of that session fails, all replacements roll back and
the durable cursor remains at the previously committed session. Rows owned by sessions outside the legacy migration set
remain untouched.
These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the
column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message`
and `part` rows rather than retaining its pre-launch V2 contents.
## Message Backfill
@@ -70,23 +36,9 @@ Backfill canonical V1 history from `message` and `part` into `session_message`.
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
V2 session APIs, which read `session_message`.
Do not fail the whole migration when a V1 message or part payload cannot be decoded. Skip an undecodable message's V2
projection and log its session and message IDs. Skip an undecodable part while continuing to map its message, and perform
special-message pairing only with decoded rows. Assign sequences after filtering. Leave every malformed source row
untouched in the V1 tables.
Skip and log orphan parts whose source message does not exist and parts with unknown or unsupported types. Continue
migrating the owning message and other valid parts. Include session, message, part ID, and observed type in warnings, and
leave skipped source rows unchanged.
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
avoid rewriting other persisted state that may refer to a message.
For ordinary user and assistant rows, preserve source `message.time_created` and `message.time_updated`. Entirely
synthetic messages preserve their source timestamps, and synthetic rows split from mixed messages use the source user
timestamps. A collapsed compaction uses the compaction user creation time and the later update time of the compaction
user and summary assistant. Keep payload creation/completion times consistent with row timestamps.
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
contiguous `session_message.seq` values starting at `0`.
@@ -94,122 +46,15 @@ Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becom
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
payload.
Keep ordinary messages even when their transformed payload becomes empty after filtering. Preserve an empty V2 user row
with `text: ""` and an empty V2 assistant row with `content: []` so IDs, chronology, and conversation structure remain
stable. Omit only explicitly dropped internal concepts and undecodable messages.
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
part mappings must be decided explicitly before implementing the backfill.
Do not carry the V1 subtask concept into the V2 projection. Omit user messages containing only `subtask` parts and omit
the paired assistant task-tool messages generated from those markers. For mixed user messages, ignore the `subtask`
parts while preserving ordinary content, and still omit assistant task-tool messages generated by the skipped subtasks.
Keep all source rows unchanged in the V1 `message` and `part` tables.
Map ordinary V1 assistant `text` and `reasoning` parts into the V2 assistant `content` array in part order. Preserve text,
including empty assistant text parts used as structural separators. Map V1 part metadata to optional V2 provider state.
For reasoning, map `time.start` to `time.created` and optional `time.end` to `time.completed`.
Preserve V1 tool parts that are `pending` or `running`, but convert them to terminal V2 tool error states. Preserve the
call ID, tool name, parsed input, metadata, and available start time. Use the assistant message creation time when the V1
state has no start time. Set the error to type `tool.interrupted` with message
`Tool execution was interrupted before V2 migration`. Never resume migrated tool executions.
For a completed V1 tool part, use `callID` as the V2 tool content ID and preserve the tool name and parsed input. Set the
state to `completed`. Convert V1 output into the first text content item and convert stored output attachments into
following file content items with their URI, MIME type, and filename. Preserve state metadata. Map `time.start` to
`time.created` and `time.end` to `time.completed`. When `time.compacted` exists, use
`[Old tool result content cleared]` as the only output and omit attachments.
For a failed V1 tool part, preserve the call ID, tool name, parsed input, metadata, and timestamps, and set the V2 state
to `error`. Convert the V1 error string to a structured error with type `tool.execution`. If V1 metadata contains a string
`output`, preserve it as optional V2 text content. Map `time.start` to `time.created` and `time.end` to `time.completed`.
For an ordinary V1 assistant message, preserve agent, provider ID, model ID, optional variant, creation and completion
times, cost, and input/output/reasoning/cache token counts. Use `default` when the V1 variant is absent. Ignore V1
`tokens.total` because it is derivable and V2 does not persist it.
Use V1 assistant `parentID` only while pairing compactions and skipped subtasks with their originating user messages. Do
not persist it in ordinary V2 assistant rows; V2 uses ordered history rather than user/assistant parent links.
Ignore the optional V1 assistant `structured` output value. V2 has no equivalent top-level assistant field, and visible
text and tool content are migrated separately. Retain the original structured value only in the V1 `message` row.
Ignore V1 assistant `mode` and historical `path` (`cwd` and `root`). Mode is redundant with the preserved assistant
agent, and historical filesystem paths do not belong to the V2 assistant message contract. Retain them only in the V1
`message` row.
For assistant finish reasons, preserve `stop`, `length`, `tool-calls`, `content-filter`, `error`, and `unknown`. Map every
other nonempty V1 finish value to `unknown`, and leave the field absent when V1 omitted it. Do not retain unrecognized raw
finish values in metadata.
Map V1 assistant errors into the current V2 `{ type, message }` storage shape. Normalize Auth, content-filter, context
overflow, structured-output, output-length, aborted, API, and unknown errors to the established V2 string conventions,
preserve the message, and discard V1-only retryability and raw provider details.
Ignore V1 `retry` parts. Do not populate the V2 assistant `retry` field during migration; historical retry state is not
useful enough to preserve. The original retry rows remain in the V1 `part` table.
Do not emit V2 assistant content for V1 `step-start` and `step-finish` parts. Use the first available
`step-start.snapshot` as `assistant.snapshot.start` and the last available `step-finish.snapshot` as
`assistant.snapshot.end`. Continue to source finish, cost, and tokens from the assistant message itself. Ignore step
markers without snapshots.
Do not emit assistant content for standalone V1 `snapshot` or `patch` parts. If no start snapshot came from `step-start`,
use the first standalone snapshot value, then the first patch hash as a final fallback. Only `step-finish.snapshot` may
populate the end snapshot. Merge patch file lists into `assistant.snapshot.files` in first-seen order with duplicates
removed.
V2 follow-up: replace the open `SessionError.Error` string shape with a properly typed persisted error union. This is not
a blocker for the V1 migration, which should target the current storage contract.
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
For an ordinary V2 user message, take visible V1 text parts that are neither ignored nor synthetic, preserve part order,
and join their text with `"\n\n"`. Use an empty string when the message contains attachments but no ordinary text.
Ignore the optional V1 user-message `system` override. Do not create a V2 system message or preserve the override in
metadata. The original value remains in the V1 `message` row.
Ignore the optional V1 user-message `tools` map. It represented request-time tool enablement for a historical step and
must not affect future V2 execution. The original value remains in the V1 `message` row.
Ignore the optional V1 user-message `format` field and its schema. It controlled structured-output behavior for a
historical request and must not affect future V2 runs. Preserve visible assistant text normally; retain the original
format only in the V1 `message` row.
Ignore V1 user-message `summary` metadata, including title, body, and diffs. V2 user messages have no equivalent field,
and session-level summary data is already persisted separately. Retain the original summary only in the V1 `message`
row.
Map V1 `agent` parts into the V2 user message's `agents` array in part order. Preserve `name`. When the V1 part has
`source`, map its `value`, `start`, and `end` into the V2 attachment's `mention.text`, `mention.start`, and `mention.end`.
Omit `agents` when there are no agent parts.
Do not read the filesystem or network while migrating V1 file attachments. Attachment migration must be deterministic
from database contents alone. Convert persisted `data:` URLs; represent non-embedded `file:`, HTTP, and other external
URLs with deterministic text rather than fetching them. Keep the original V1 `part` rows unchanged.
For a V1 file backed by a `data:` URL, decode the URL and normalize its payload to base64 for the V2 attachment's `data`.
Preserve `mime` and optional `filename` as `name`. Use a V2 `uri` source with the original URI for a V1 resource source;
otherwise use an `inline` source. When V1 source text metadata exists, map its `value`, `start`, and `end` into the V2
attachment mention. Leave `description` unset and preserve file-part order in the V2 `files` array.
For a non-embedded V1 file, do not create a V2 file attachment. Append
`[Attachment unavailable after migration: <name-or-url> (<mime>)]` to the V2 user text in original part order, separated
by blank lines. Prefer the V1 filename, then resource URI, then part URL for the label. The original URL remains only in
the preserved V1 `part` row.
For a synthetic row split from a mixed user message, derive a generated-looking ID from the source message ID. Preserve
the source ID's 12-character timestamp component and replace its 14-character random component with a deterministic
base-62 encoding of a hash of `v1-synthetic:` plus the source message ID. If that candidate collides with an existing or
derived message ID, deterministically retry with an incrementing salt. Place the synthetic row immediately after its
source user row. Entirely synthetic messages continue to reuse their original message ID.
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
admitted compaction input ID and preserves references to the initiating message.
@@ -219,13 +64,9 @@ serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
assistant row.
Do not project incomplete or failed V1 compactions into `session_message`. Omit both the internal compaction user marker
and its paired summary assistant when no successful summary was completed. Assign final sequence numbers after filtering
so omitted compactions leave no gaps. Their source rows remain preserved in the V1 `message` and `part` tables.
After rebuilding a session's `session_message`, replace its `event_sequence` watermark with that session's maximum
backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting before migrated
history. The migrated session's prior `event` rows are removed in the same transaction.
After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that
session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting
before migrated history. The `event` table remains empty.
## Drop
@@ -233,7 +74,6 @@ Drop these pre-launch V2 tables without preserving or transforming their rows:
- `session_input`
- `session_context_epoch`
- `data_migration`
Do not transfer `session_input` rows into `session_pending`.
@@ -263,25 +103,16 @@ schema.
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
## Execution
## Verification
Store V1 backfill state in `kv`; do not retain a dedicated `data_migration` table. Store the last successfully migrated
session ID under `migration.v1-v2.session.cursor` and write `migration.v1-v2.completed` with value `true` after every
session finishes. Delete the cursor key on completion and return immediately on later calls when the completion key
exists.
The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
credentials, permissions, shares, and workspaces. After migration, it should verify:
Absence of the completion key means migration is required, including on a fresh database. Running the endpoint against a
database with no sessions completes immediately and writes the completion key; fresh database initialization does not
seed migration state specially.
Process sessions in stable ID order. Rebuild one session in one transaction, including its `session_message` rows,
session-level backfills, `event_sequence` watermark, and cursor update. If interrupted during a session, that transaction
rolls back and the next endpoint call retries the same session. If it committed, the next call continues after the stored
cursor. Mark the migration complete after the final session and return immediately on later calls.
Process every `session` row, including archived, root, child, and empty sessions, as well as sessions whose messages are
all skipped or internal. Each successfully committed session advances the cursor.
## Testing
Detailed migration test design is deferred until after the canonical migration is implemented.
- Preserved rows and encoded values remain unchanged.
- Todo rows remain available in the unchanged `todo` table.
- `event` is empty, and stale pre-launch rows are absent from the rebuilt projections.
- Backfilled `session_message` rows represent the canonical V1 `message` and `part` history.
- Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence.
- Dropped tables no longer exist.
- New tables exist and are empty.
- The final schema has no ungenerated changes.
@@ -45,6 +45,10 @@ describe("timeline fixture validation", () => {
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
})
test("uses the projected tool ID as its call ID", () => {
expect(toolPart("call_1", "read", "running", {})).toMatchObject({ id: "call_1", callID: "call_1" })
})
})
if (false) {
@@ -10,7 +10,6 @@ import {
status,
textPart,
title,
userID,
userMessage,
} from "../performance/timeline-stability/fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
@@ -19,18 +18,22 @@ import { expectSessionTitle } from "../utils/waits"
const initialPageSize = 20
const historyPageSize = 200
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
parentID: userID,
created: 1700000001000 + index * 1_000,
completed: index < initialPageSize,
}),
)
const messages = [userMessage(), ...assistants]
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
return [
userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
parentID: id,
created: 1700000001000 + index * 2_000,
completed: index < initialPageSize,
}),
]
}).flat()
const assistants = messages.filter((message) => message.info.role === "assistant")
const lastAssistant = assistants.at(-1)!
const lastPartID = assistants.at(-1)!.parts[0]!.id
const userPartID = `prt_${userID}_text`
const lastPartID = `${assistants.at(-1)!.info.id}:text:0`
const userPartID = `${messages.at(-2)!.info.id}:text:0`
const completed = {
...lastAssistant.info,
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
@@ -59,6 +62,7 @@ for (const scenario of scenarios) {
retry: 20,
})
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: project(),
provider: {
@@ -154,15 +158,23 @@ for (const scenario of scenarios) {
await expectSessionTitle(page, title)
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await viewport.hover()
const deadline = Date.now() + 10_000
while (requests.filter((request) => request.phase === "start").length < 2) {
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
await page.mouse.wheel(0, -240)
await page.waitForTimeout(20)
}
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
expect(sequence.slice(0, 4)).toEqual([
expect(sequence.slice(0, 3)).toEqual([
"messages:start:latest",
"messages:end:latest",
`message:${userID}`,
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
])
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
initialPageSize / 2,
)
await page.evaluate(() => {
;(
window as Window & {
@@ -174,7 +186,9 @@ for (const scenario of scenarios) {
expect(await visibleContentHidden(page)).toBe(false)
const beforeHistory = await probeSamples(page)
history.resolve()
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
await expect
.poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count())
.toBeGreaterThan(initialPageSize / 2)
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await waitForProbeSamples(page, beforeHistory)
@@ -182,7 +196,7 @@ for (const scenario of scenarios) {
{ before: undefined, limit: initialPageSize },
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
])
expect(roots).toEqual([{ sessionID, messageID: userID }])
expect(roots).toEqual([])
const message = messageUpdated(scenario.info)
const idle = status("idle")
@@ -10,7 +10,6 @@ const title = "Hidden terminal regression"
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -32,7 +32,7 @@ test("keeps the terminal session alive when switching session tabs in a workspac
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
expect(connection.searchParams.get("location[directory]")).toBe(directory)
expect(connection.searchParams.get("ticket")).toBeNull()
expect(connection.searchParams.get("ticket")).toBe("e2e-ticket")
await writeProbe(page)
await switchTab(page, titleB)
@@ -66,7 +66,6 @@ async function readProbe(page: Page) {
async function setup(page: Page) {
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -21,7 +21,7 @@ const words = [
"vector",
]
const serverKey = "http://127.0.0.1:4096"
const serverKey = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject"
@@ -134,7 +134,7 @@ function toolPart(
return {
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
callID: id("call", index * 10 + partIndex),
callID: id("call", index * 100 + partIndex),
tool,
state: {
status: "completed",
@@ -235,8 +235,17 @@ function renderable(part: MessagePart) {
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
function orderedParts(message: Message) {
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
function currentPartIDs(message: Message) {
const ordinals = { text: 0, reasoning: 0 }
return message.parts
.flatMap((part) => {
if (!renderable(part)) return []
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
return []
})
.sort()
}
export const fixture = {
@@ -290,12 +299,10 @@ export const fixture = {
targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
targetPartIDs: targetMessages.flatMap(currentPartIDs),
expandedShellPartID: targetMessages
.flatMap((message) => message.parts)
.find((part) => part.tool === "bash")!.callID,
},
}
@@ -33,6 +33,7 @@ test.describe("smoke: session timeline", () => {
test("keeps the visible message fixed while prepending history", async ({ page }) => {
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -91,6 +92,7 @@ test.describe("smoke: session timeline", () => {
test("preserves the timeline gap above the composer", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -117,6 +119,7 @@ test.describe("smoke: session timeline", () => {
test("paints cached session tabs at the latest message", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -125,20 +128,19 @@ test.describe("smoke: session timeline", () => {
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
({ server, sourceID, targetID }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server: "http://127.0.0.1:4096",
dirBase64,
server,
sessionId,
})),
),
)
},
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
@@ -243,6 +245,7 @@ test.describe("smoke: session timeline", () => {
test("paints a cold session tab at the latest message", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -251,20 +254,19 @@ test.describe("smoke: session timeline", () => {
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
({ server, sourceID, targetID }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server: "http://127.0.0.1:4096",
dirBase64,
server,
sessionId,
})),
),
)
},
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
await expectSessionTitle(page, fixture.expected.sourceTitle)
@@ -322,6 +324,7 @@ test.describe("smoke: session timeline", () => {
test("renders seeded timeline in order while paging through history", async ({ page }) => {
const errors = trackPageErrors(page)
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -6,7 +6,7 @@ const directory = "C:/OpenCode/NewProject"
test("creates a session in a new project and selects its model", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v1",
protocol: "v2",
directory,
project: {
id: "proj_model_selection_flow",
+1 -1
View File
@@ -80,7 +80,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
)
}
if (path === "/global/health")
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
return config.protocol === "v2" ? json(route, {}) : json(route, { healthy: true })
if (path === "/api/health" && config.protocol === "v2")
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
@@ -146,7 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
server: ServerConnection.key(serverSDK.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
load: (search, signal) => serverSDK.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -79,7 +79,7 @@ export function DialogHomeCommandPaletteV2(props: {
server: ServerConnection.key(props.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
load: (search, signal) => serverCtx.sdk.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -418,7 +418,7 @@ function ProviderConnection(props: {
() => ({ provider: props.provider, directory: directory() }),
(input) =>
serverSDK()
.api.integration.get({
.currentApi.integration.get({
integrationID: input.provider,
location: input.directory ? { directory: input.directory } : undefined,
})
@@ -547,7 +547,7 @@ function ProviderConnection(props: {
}
dispatch({ type: "auth.pending" })
await serverSDK()
.api.integration.oauth.connect({
.currentApi.integration.oauth.connect({
integrationID: props.provider,
methodID: method.id,
inputs: inputs ?? {},
@@ -816,7 +816,7 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
await serverSDK().api.integration.connect.key({
await serverSDK().currentApi.integration.connect.key({
integrationID: props.provider,
location: location(),
key: apiKey,
@@ -947,7 +947,7 @@ function ProviderConnection(props: {
setFormStore("error", undefined)
const result = await serverSDK()
.api.integration.oauth.complete({
.currentApi.integration.oauth.complete({
integrationID: props.provider,
attemptID: store.authorization!.attemptID,
location: location(),
@@ -1044,7 +1044,7 @@ function ProviderConnection(props: {
const authorization = store.authorization
if (!authorization || !alive.value) return
const result = await serverSDK()
.api.integration.oauth.status({
.currentApi.integration.oauth.status({
integrationID: props.provider,
attemptID: authorization.attemptID,
location: location(),
@@ -136,7 +136,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) {
await serverSDK().client.auth.set({
await serverSDK().legacy.auth.set({
providerID: result.providerID,
auth: {
type: "api",
+1 -1
View File
@@ -69,7 +69,7 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory)
sdk()
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
.currentApi.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
.then((forked) => {
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
@@ -70,10 +70,17 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) !== "v1") return
return sdk.client.path
if ((await sdk.protocol) === "v1")
return sdk.legacy.path.get().catch(() => undefined)
return sdk.api.location
.get()
.then((result) => result.data)
.then((location) => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}))
.catch(() => undefined)
},
{ initialValue: undefined },
@@ -97,7 +104,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const base = pickerRoot(cleaned) || root() || start()
if (!base) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file
const files = await sdk.currentApi.file
.find({
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
@@ -127,7 +134,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.api.file
return sdk.currentApi.file
.list({ location: { directory: absolute } })
.then((result) =>
result.data.map((entry) => ({
@@ -61,10 +61,17 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) !== "v1") return
return sdk.client.path
if ((await sdk.protocol) === "v1")
return sdk.legacy.path.get().catch(() => undefined)
return sdk.api.location
.get()
.then((result) => result.data)
.then((location) => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}))
.catch(() => undefined)
},
{ initialValue: undefined },
@@ -133,7 +133,7 @@ test("scopes file autocomplete to the current browser root", () => {
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
const sdk = {
api: {
currentApi: {
file: {
find: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
@@ -155,7 +155,7 @@ test("resolves directory autocomplete from the current browser root", async () =
test("searches from an absolute root without a default base", async () => {
const directories: string[] = []
const sdk = {
api: {
currentApi: {
file: {
list: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
@@ -342,7 +342,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const key = trimPickerPath(directory)
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.api.file
const request = args.sdk.currentApi.file
.list({ location: { directory: key } })
.then((result) => result.data)
.catch(() => [])
@@ -374,7 +374,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.api.file
const results = await args.sdk.currentApi.file
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path))
.catch(() => [])
+1 -7
View File
@@ -73,7 +73,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
if (props.project.id && props.project.id !== "global") {
if ((await serverCtx().sdk.protocol) !== "v1") return
const project = await serverCtx()
.sdk.client.project.update({
.sdk.legacy.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
@@ -82,12 +82,6 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
})
.then((result) => result.data)
if (!project) return
// const project = await serverCtx().sdk.api.project.update({
// projectID: props.project.id,
// name,
// icon: { color: store.color || "", override: store.iconOverride || "" },
// commands: { start },
// })
serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
)
@@ -199,6 +199,7 @@ beforeAll(async () => {
directory: "/repo/main",
client: rootClient,
api: rootClient.api,
currentApi: rootClient.api,
url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
@@ -332,7 +333,7 @@ describe("prompt submit worktree selection", () => {
selected = "/repo/worktree-b"
await submit.handleSubmit(event)
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdClients).toEqual([])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sessionCreateInputs).toEqual([
{
@@ -489,9 +490,6 @@ describe("prompt submit worktree selection", () => {
agents: [],
})
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([
{ id: expect.stringMatching(/^prt_/), type: "text", text: "ls" },
])
})
test("submits slash commands through the current session API", async () => {
@@ -22,6 +22,7 @@ import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event"
import { getDirectory } from "@opencode-ai/core/util/path"
type PendingPrompt = {
abort: AbortController
@@ -41,7 +42,7 @@ export type FollowupDraft = {
}
type FollowupSendInput = {
api: DirectorySDK["api"]["session"]
api: DirectorySDK["currentApi"]["session"]
serverSync: ServerSync
sync: DirectorySync
draft: FollowupDraft
@@ -159,10 +160,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
await input.api.prompt({
sessionID: input.draft.sessionID,
id: messageID,
agent: input.draft.agent,
model: input.draft.model,
variant: input.draft.variant,
legacyParts: requestParts,
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
files: requestParts.flatMap((part) => {
if (part.type !== "file") return []
@@ -264,7 +261,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return Promise.resolve()
}
return sdk()
.api.session.interrupt({ sessionID })
.currentApi.session.interrupt({ sessionID })
.catch(() => {})
}
@@ -348,13 +345,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory
let client = sdk().client
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await client.worktree
.create({ directory: projectDirectory })
.then((x) => x.data)
const createdWorktree = await sdk()
.currentApi.projectCopy.create({
projectID: sync().data.project,
strategy: "git_worktree",
directory: getDirectory(projectDirectory),
location: { directory: projectDirectory },
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
@@ -363,13 +363,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return undefined
})
if (!createdWorktree?.directory) {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: language.t("common.requestFailed"),
})
return
}
if (!createdWorktree) return
WorktreeState.pending(sdk().scope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory
}
@@ -379,10 +373,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
if (sessionDirectory !== projectDirectory) {
client = sdk().createClient({
directory: sessionDirectory,
throwOnError: true,
})
serverSync().child(sessionDirectory)
}
@@ -392,7 +382,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info()
if (!session && isNewSession) {
const created = await sdk()
.api.session.create({
.currentApi.session.create({
agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
@@ -483,12 +473,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput()
const eventID = Event.ID.create()
sdk()
.api.session.shell({
.currentApi.session.shell({
sessionID: session.id,
id: eventID,
command: text,
agent,
model,
})
.catch((err) => {
showToast({
@@ -509,7 +497,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" })
sdk()
.api.session.command({
.currentApi.session.command({
sessionID: session.id,
id: messageID,
command: commandName,
@@ -606,7 +594,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
void sendFollowupDraft({
api: sdk().api.session,
api: sdk().currentApi.session,
sync: sync(),
serverSync: serverSync(),
draft,
@@ -13,7 +13,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import {
monoDefault,
@@ -125,16 +125,11 @@ export const SettingsGeneral: Component = () => {
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
@@ -325,10 +320,11 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Show when={protocol() === "v1"}>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
options={shellOptions()}
@@ -345,7 +341,8 @@ export const SettingsGeneral: Component = () => {
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -122,16 +122,18 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSDK()
.client.auth.remove({ providerID })
.catch(() => undefined)
await serverSDK().legacy.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
await serverSDK()
.client.auth.remove({ providerID })
.then(async () => {
await serverSDK().client.global.dispose()
.currentApi.integration.get({ integrationID: providerID })
.then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all(
credentials.map((credential) => serverSDK().currentApi.credential.remove({ credentialID: credential.id })),
)
showToast({
variant: "success",
icon: "circle-check",
@@ -10,7 +10,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
import {
monoDefault,
@@ -92,6 +92,7 @@ export const SettingsGeneralV2: Component<{
const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
@@ -122,14 +123,8 @@ export const SettingsGeneralV2: Component<{
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
@@ -284,10 +279,11 @@ export const SettingsGeneralV2: Component<{
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Show when={protocol() === "v1"}>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
@@ -303,7 +299,8 @@ export const SettingsGeneralV2: Component<{
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
</SettingsRowV2>
</Show>
<SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -119,16 +119,21 @@ export const SettingsProvidersV2: Component<{
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSdk()
.client.auth.remove({ providerID })
.catch(() => undefined)
await serverSdk().legacy.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
const location = props.directory() ? { directory: props.directory() } : undefined
await serverSdk()
.client.auth.remove({ providerID })
.then(async () => {
await serverSdk().client.global.dispose()
.currentApi.integration.get({ integrationID: providerID, location })
.then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all(
credentials.map((credential) =>
serverSdk().currentApi.credential.remove({ credentialID: credential.id, location }),
),
)
showToast({
variant: "success",
icon: "circle-check",
@@ -318,10 +318,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")}
</Tabs.Trigger>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
<Show when={protocol() === "v1"}>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
</Show>
<Show when={protocol() === "v1"}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
@@ -459,7 +461,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div>
</Tabs.Content>
<Tabs.Content value="lsp">
<Show when={protocol() === "v1"}>
<Tabs.Content value="lsp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
@@ -485,7 +488,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</Show>
</div>
</div>
</Tabs.Content>
</Tabs.Content>
</Show>
<Show when={protocol() === "v1"}>
<Tabs.Content value="plugins">
+20 -57
View File
@@ -17,6 +17,7 @@ import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
import { authTokenFromCredentials } from "@/utils/server"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -182,8 +183,6 @@ export const Terminal = (props: TerminalProps) => {
const auth = connection.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
const authToken = connection.type === "http" ? connection.authToken : false
const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement
const [local, others] = splitProps(props, [
"pty",
@@ -241,18 +240,8 @@ export const Terminal = (props: TerminalProps) => {
}
const pushSize = async (cols: number, rows: number) => {
if ((await sdk().protocol) === "v1") {
return sdk()
.client.pty.update({
ptyID: id,
size: { cols, rows },
})
.catch((err) => {
debugTerminal("failed to sync terminal size", err)
})
}
return sdk()
.api.pty.update({
.currentApi.pty.update({
ptyID: id,
location: { directory },
size: { cols, rows },
@@ -533,17 +522,8 @@ export const Terminal = (props: TerminalProps) => {
}
const gone = async () => {
if ((await sdk().protocol) === "v1") {
return sdk()
.client.pty.get({ ptyID: id }, { throwOnError: false })
.then((result) => result.response.status === 404)
.catch((err) => {
debugTerminal("failed to inspect terminal session", err)
return false
})
}
return sdk()
.api.pty.get({ ptyID: id, location: { directory } })
.currentApi.pty.get({ ptyID: id, location: { directory } })
.then((result) => result.data.status === "exited")
.catch((err) => {
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
@@ -553,33 +533,23 @@ export const Terminal = (props: TerminalProps) => {
}
const connectToken = async () => {
if ((await sdk().protocol) === "v1") {
const result = await sdk()
.client.pty.connectToken(
{ ptyID: id, directory },
{
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
.catch((err: unknown) => {
if (err instanceof Error && err.message.includes("Request is not supported")) return
throw err
})
if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
}
// return sdk()
// .api.pty.connectToken({
// ptyID: id,
// location: { directory },
// "x-opencode-ticket": "1",
// })
// .then((result) => result.data.ticket)
const endpoint = new URL(`/api/pty/${encodeURIComponent(id)}/connect-token`, url)
endpoint.searchParams.set("location[directory]", directory)
const response = await (platform.fetch ?? globalThis.fetch)(endpoint, {
method: "POST",
headers: {
"x-opencode-ticket": "1",
...(password
? { Authorization: `Basic ${authTokenFromCredentials({ username, password })}` }
: undefined),
},
})
if (response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
if (!response.ok) throw new Error(`PTY connect ticket failed with ${response.status}`)
const result = (await response.json()) as { data?: { ticket?: string } }
if (!result.data?.ticket) throw new Error("PTY connect ticket response did not include a ticket")
return result.data.ticket
}
const retry = (err: unknown) => {
@@ -609,23 +579,16 @@ export const Terminal = (props: TerminalProps) => {
fail(err)
return undefined
})
const protocol = await sdk().protocol
// if (protocol === "v2" && !ticket) return
if (once.value) return
if (disposed) return
const socket = new WebSocket(
terminalWebSocketURL({
protocol,
url,
id,
directory,
cursor: seek,
ticket,
sameOrigin,
username,
password,
authToken,
}),
)
socket.binaryType = "arraybuffer"
@@ -105,7 +105,7 @@ function SessionTabEntry(props: {
ctx.sync.session.remember({ ...value, title })
try {
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
await ctx.sdk.currentApi.session.rename({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx()
+1 -1
View File
@@ -192,7 +192,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
},
({ route, sdk }) =>
sdk.api.session
sdk.currentApi.session
.get({ sessionID: route.sessionId })
.then(normalizeSessionInfo)
.catch(() => {}),
+2 -3
View File
@@ -124,7 +124,7 @@ export const createDirSyncContext = (
fetch: async (count = 10) => {
const [store, setStore] = current()
setStore("limit", (value) => value + count)
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
const response = await serverSDK.currentApi.session.list({ directory, limit: store.limit, order: "desc" })
const sessions = response.data
.map(normalizeSessionInfo)
.sort((a, b) => cmp(a.id, b.id))
@@ -134,8 +134,7 @@ export const createDirSyncContext = (
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
if ((await serverSDK.protocol) !== "v1") return
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
await serverSDK.legacy.session.archive(sessionID, directory)
current()[1](
"session",
produce((draft) => {
+13 -6
View File
@@ -81,8 +81,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
normalizeDir: path.normalizeDir,
list: (dir) =>
sdk()
.client.file.list({ path: dir })
.then((x) => x.data ?? []),
.currentApi.file.list({ path: dir, location: { directory: scope() } })
.then((x) =>
x.data.map((entry) => ({
...entry,
name: entry.path.split("/").at(-1) ?? entry.path,
absolute: `${scope()}/${entry.path}`,
ignored: false,
})),
),
onError: (message) => {
showToast({
variant: "error",
@@ -181,10 +188,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file)
const promise = sdk()
.client.file.read({ path: file })
.then((x) => {
.currentApi.file.read({ path: file, location: { directory } })
.then((data) => {
if (scope() !== directory) return
const content = x.data
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
setLoaded(file, content)
if (!content) return
@@ -205,7 +212,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
serverSDK()
.api.file.find(
.currentApi.file.find(
{
location: { directory: sdk().directory },
query,
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, Project } from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { LegacyCapabilities } from "@/utils/server-compat"
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import {
@@ -76,9 +76,30 @@ function directoryState() {
}
describe("bootstrapDirectory", () => {
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
test("uses current MCP endpoints while retaining unsupported v1 directory reads", async () => {
const mcpReads: string[] = []
const [store, setStore] = directoryState()
const currentApi = {
...api,
command: {
list: async () => {
mcpReads.push("command")
return { location: {}, data: [] }
},
},
mcp: {
list: async () => {
mcpReads.push("status")
return { location: {}, data: [] }
},
resource: {
catalog: async () => {
mcpReads.push("resource")
return { location: {}, data: { resources: [], templates: [] } }
},
},
},
} as unknown as ServerApi
await bootstrapDirectory({
directory: "/project",
@@ -90,37 +111,8 @@ describe("bootstrapDirectory", () => {
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
sdk: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: {
list: async () => {
mcpReads.push("command")
return { data: [] }
},
},
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
mcp: {
status: async () => {
mcpReads.push("status")
return { data: {} }
},
},
experimental: {
resource: {
list: async () => {
mcpReads.push("resource")
return { data: {} }
},
},
},
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
api,
legacy: { config: { directory: async () => ({}) } } as unknown as LegacyCapabilities,
api: currentApi,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
@@ -141,12 +133,20 @@ describe("bootstrapDirectory", () => {
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const client = {} as Parameters<typeof loadPathQuery>[2]
const location = {} as Parameters<typeof loadPathQuery>[2]
const api = {} as CatalogApi
const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual([
"local",
"/repo",
"path",
])
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual([
"https://debian.example",
"/repo",
"path",
])
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
})
+55 -111
View File
@@ -8,7 +8,7 @@ import type {
ReferenceInfo,
Session,
} from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { LegacyCapabilities } from "@/utils/server-compat"
import type {
AgentListInput,
AgentListOutput,
@@ -16,6 +16,8 @@ import type {
CommandInfo,
CommandListInput,
CommandListOutput,
LocationGetInput,
LocationGetOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectListOutput,
@@ -105,16 +107,18 @@ function showErrors(input: {
})
}
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
export const loadGlobalConfigQuery = (scope: ServerScope, legacy: LegacyCapabilities, enabled = true) =>
queryOptions({
queryKey: [scope, "config"],
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
queryFn: () => retry(() => legacy.config.global()),
enabled,
})
type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
type McpApi = ServerApi["mcp"]
type PermissionApi = ServerApi["permission"]
@@ -138,8 +142,8 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
})
export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient
serverAPI: CatalogApi & { readonly project: ProjectApi }
legacy: LegacyCapabilities
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
protocol?: Promise<ServerProtocol>
scope: ServerScope
requestFailedTitle: string
@@ -148,18 +152,22 @@ export async function bootstrapGlobal(input: {
setGlobalStore: SetStoreFunction<GlobalStore>
queryClient: QueryClient
}) {
const protocol = await input.protocol
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
protocol === "v1" && (() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.legacy))),
() =>
input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
loadProvidersQuery(input.scope, null, input.serverAPI),
),
() =>
input.queryClient.fetchQuery(
loadPathQuery(input.scope, null, input.serverAPI.location),
),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
.then((data) => input.setGlobalStore("project", data)),
]
].filter(Boolean) as Array<() => Promise<unknown>>
await runAll(slow)
// showErrors({
// errors: errors(),
@@ -219,17 +227,11 @@ export const loadProvidersQuery = (
scope: ServerScope,
directory: string | null,
sdk: CatalogApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
queryOptions({
queryKey: [scope, directory, "providers"],
queryFn: () =>
retry(async () => {
if ((await protocol) === "v1" && legacy) {
const result = await legacy.provider.list()
return normalizeProviderList(result.data!)
}
const location = directory ? { location: { directory } } : undefined
const [providers, models, defaultModel] = await Promise.all([
sdk.provider.list(location),
@@ -256,71 +258,45 @@ export const loadAgentsQuery = (
scope: ServerScope,
directory: string,
sdk: AgentListApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
queryOptions({
queryKey: [scope, directory, "agents"],
queryFn: () =>
retry(async () => {
if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
}),
retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
})
export const loadCommands = (
directory: string,
api: CommandListApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
): Promise<CommandInfo[]> =>
retry(async () => {
if ((await protocol) === "v1" && legacy) {
return ((await legacy.command.list()).data ?? []).map((command) => {
const [providerID, id] = command.model?.split("/") ?? []
return {
name: command.name,
template: command.template,
description: command.description,
agent: command.agent,
model: providerID && id ? { providerID, id } : undefined,
subtask: command.subtask,
// source: command.source === "skill" ? undefined : command.source,
}
})
}
return api.list({ location: { directory } }).then((result) => result.data)
})
retry(() => api.list({ location: { directory } }).then((result) => result.data))
export const loadPathQuery = (
scope: ServerScope,
directory: string | null,
sdk: OpencodeClient,
protocol?: Promise<ServerProtocol>,
api: LocationApi,
) =>
queryOptions<Path>({
queryKey: [scope, directory, "path"],
queryFn: async () => {
if ((await protocol) !== "v1")
return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" }
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
},
queryFn: () =>
retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
})),
})
export const loadReferencesQuery = (
scope: ServerScope,
directory: string,
api: ReferenceListApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
queryOptions<ReferenceInfo[]>({
queryKey: [scope, directory, "references"] as const,
queryFn: () =>
retry(async () => {
if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? []
return api.list({ location: { directory } }).then((result) => result.data)
}).catch(() => []),
retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
placeholderData: [],
})
@@ -328,7 +304,7 @@ export async function bootstrapDirectory(input: {
directory: string
scope: ServerScope
mcp: boolean
sdk: OpencodeClient
legacy: LegacyCapabilities
api: CatalogApi & {
readonly agent: AgentListApi
readonly command: CommandListApi
@@ -339,6 +315,7 @@ export async function bootstrapDirectory(input: {
readonly reference: ReferenceListApi
readonly session: SessionApi
readonly vcs: VcsApi
readonly location: LocationApi
}
store: Store<State>
setStore: SetStoreFunction<State>
@@ -373,37 +350,15 @@ export async function bootstrapDirectory(input: {
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
input.queryClient
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
.then((data) => input.setStore("agent", data)),
() =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
() =>
retry(() =>
(async () => {
if ((await input.protocol) !== "v1") return
const x = await input.sdk.session.status()
if (!input.session) {
input.setStore("session_status", x.data!)
return
}
const statuses = x.data ?? {}
input.session.set(
"session_status",
produce((draft) => {
for (const sessionID of Object.keys(draft)) {
if (statuses[sessionID]) continue
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
}
}),
)
for (const [sessionID, status] of Object.entries(statuses)) {
input.session.set("session_status", sessionID, reconcile(status))
}
await Promise.all(
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
)
})(),
),
(await input.protocol) === "v1" &&
(() =>
retry(() =>
input.legacy.config
.directory(input.directory)
.then((config) => input.setStore("config", reconcile(config, { merge: false }))),
)),
!seededProject &&
(() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
@@ -412,37 +367,28 @@ export async function bootstrapDirectory(input: {
!seededPath &&
(() =>
input.queryClient
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol))
.ensureQueryData(
loadPathQuery(input.scope, input.directory, input.api.location),
)
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
() =>
retry(async () => {
if ((await input.protocol) !== "v1") return
return input.sdk.vcs.get().then((result) => {
const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
})
}),
input.mcp &&
(() =>
loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
loadCommands(input.directory, input.api.command).then((commands) =>
input.setStore("command", commands),
)),
() =>
input.queryClient.fetchQuery(
loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol),
loadReferencesQuery(input.scope, input.directory, input.api.reference),
),
() =>
retry(() =>
(async () => {
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
return input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data.map(normalizePermissionRequest))
})().then((permissions) => {
input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data.map(normalizePermissionRequest))
.then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession(
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
@@ -473,12 +419,10 @@ export async function bootstrapDirectory(input: {
),
() =>
retry(() =>
(async () => {
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
return input.api.question.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
})().then((questions) => {
input.api.question.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((questions) => {
const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
@@ -511,16 +455,16 @@ export async function bootstrapDirectory(input: {
input.mcp &&
(() =>
input.queryClient.fetchQuery(
loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
loadMcpQuery(input.scope, input.directory, input.api.mcp),
)),
input.mcp &&
(() =>
input.queryClient.fetchQuery(
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp),
)),
() =>
input.queryClient
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api))
.catch((err) => {
const project = getFilename(input.directory)
showToast({
@@ -191,7 +191,10 @@ export function createChildStoreManager(input: {
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const lspQuery = useQuery(() => {
const options = input.queryOptions.lsp(key)
return { ...options, enabled: options.enabled !== false && instanceQueriesEnabled() }
})
const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
enabled: instanceQueriesEnabled(),
@@ -1,6 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise"
import { normalizeSessionInfo } from "@/utils/session"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
const result = await input.api.list({
@@ -16,16 +15,6 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
} as const
}
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
try {
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
return { data: result.data, limit: input.limit, limited: true } as const
} catch {
const result = await input.client.session.list({ directory: input.directory, roots: true })
return { data: result.data, limit: input.limit, limited: false } as const
}
}
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
if (!input.limited) return input.count
if (input.count < input.limit) return input.count
+1 -1
View File
@@ -574,7 +574,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
void (async () => {
const sdk = serverSdk()
if ((await sdk.protocol) !== "v1") return
return sdk.client.project
return sdk.legacy.project
.update({ projectID, directory: worktree, icon: { color } })
.then((response) => response.data)
.then((result) => {
-3
View File
@@ -258,9 +258,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
}
const list = async (directory: string) => {
if ((await input.sdk.protocol) === "v1") {
return (await input.sdk.client.permission.list({ directory })).data ?? []
}
return input.sdk.api.permission.request
.list({ location: { directory } })
.then((result) => result.data.map(normalizePermissionRequest))
+2 -2
View File
@@ -1,8 +1,8 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, createMemo } from "solid-js"
import { type ServerSDK, useServerSDK } from "./server-sdk"
import { type DirectorySDK, useServerSDK } from "./server-sdk"
export type DirectorySDK = ReturnType<ServerSDK["ensureDirSdkContext"]>
export type { DirectorySDK }
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
+39 -21
View File
@@ -12,7 +12,13 @@ import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
import {
createCompatibleApi,
createLegacyCapabilities,
type CompatibleApi,
type LegacyCapabilities,
} from "@/utils/server-compat"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
@@ -165,6 +171,7 @@ type ServerSDKBase = {
url: string
client: ReturnType<typeof createSdkForServer>
api: CompatibleApi
legacy: LegacyCapabilities
currentApi: ServerApi
event: {
on: ServerEventEmitter["on"]
@@ -192,11 +199,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
})()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
server: server.http,
})
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
const [protocolKind] = createResource(
() => protocol,
@@ -264,18 +266,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
abort.signal.addEventListener("abort", onAbort)
try {
const kind = await protocol
const events =
kind === "v1"
? (await eventSdk.global.event({ signal: attempt.signal })).stream
: eventApi.event.subscribe({ signal: attempt.signal })
const events = eventApi.event.subscribe({ signal: attempt.signal })
let yielded = Date.now()
for await (const event of events) {
streamErrorLogged = false
const legacy = "payload" in event
if (legacy && event.payload.type === "sync") continue
const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
const directory = event.location?.directory ?? "global"
const payload = adaptServerEvent(event)
if (enqueueServerEvent(queue, { directory, payload })) schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
@@ -339,6 +335,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
directory,
})
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
const capabilities = createLegacyCapabilities({ protocol, current: currentApi, legacy })
return {
server,
@@ -348,6 +345,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
url: server.http.url,
client: sdk,
api,
legacy: capabilities,
currentApi,
event: {
on: emitter.on.bind(emitter),
@@ -364,8 +362,25 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
}
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
export type DirectorySDK = {
scope: ServerScope
protocol: Promise<ServerProtocol>
directory: string
client: OpencodeClient
currentApi: ServerApi
api: CompatibleApi
legacy: LegacyCapabilities
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
readonly url: string
createClient: ServerSDKBase["createClient"]
}
export type ServerSDK = ServerSDKBase & {
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
ensureDirSdkContext: (directory: string) => DirectorySDK
}
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
@@ -397,11 +412,7 @@ export function useServerProtocol() {
return createMemo(() => serverSDK().protocolKind())
}
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
const client = serverSDK.createClient({
directory,
throwOnError: true,
@@ -419,12 +430,19 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
protocol: serverSDK.protocol,
directory,
client,
currentApi: serverSDK.currentApi,
api: createCompatibleApi({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
legacy: createLegacyCapabilities({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
event: emitter,
get url() {
return serverSDK.url
@@ -265,6 +265,34 @@ describe("server session", () => {
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.history.more("root")).toBe(false)
})
test("replaces stale current projections on complete refreshes", async () => {
const first = { id: "msg_1", type: "user", text: "first", time: { created: 1 } } as const
const second = { id: "msg_2", type: "user", text: "second", time: { created: 2 } } as const
const pages = [
{ data: [first], cursor: { previous: null, next: null } },
{ data: [second], cursor: { previous: null, next: null } },
{ data: [], cursor: { previous: null, next: null } },
]
const messageApi = {
list: async () => pages.shift()!,
} as unknown as MessageApi
const sessionApi = { get: async () => session("root") } as unknown as SessionApi
const store = createServerSession({} as OpencodeClient, sessionApi, messageApi)
store.remember(session("root"))
await store.sync("root")
expect(store.data.session_message.root.map((message) => message.id)).toEqual([first.id])
await store.sync("root", { force: true })
expect(store.data.session_message.root.map((message) => message.id)).toEqual([second.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([second.id])
await store.sync("root", { force: true })
expect(store.data.session_message.root).toEqual([])
expect(store.data.message.root).toEqual([])
})
test("extends a current page to include the user for split assistant turns", async () => {
+20 -7
View File
@@ -10,7 +10,7 @@ import type {
SessionStatus,
Todo,
} from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { LegacyCapabilities } from "@/utils/server-compat"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -183,10 +183,14 @@ function reconcileFetched<T extends { id: string }>(
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
}
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
type ServerSessionOptions = {
retry?: typeof retry
protocol?: Promise<"v1" | "v2">
legacy?: LegacyCapabilities
}
export function createServerSession(
client: OpencodeClient,
client: { session: Pick<LegacyCapabilities["session"], "get" | "messages" | "message"> },
sessionApiOrOptions?: SessionApi | ServerSessionOptions,
messageApi?: MessageApi,
currentOptions?: ServerSessionOptions,
@@ -563,7 +567,7 @@ export function createServerSession(
sourceMode: before ? ("older" as const) : ("latest" as const),
projectSource: true,
cursor: response.cursor.next ?? undefined,
complete: response.data.length === 0,
complete: !response.cursor.next,
}
}
const response = await (options?.retry ?? retry)(() => {
@@ -683,7 +687,14 @@ export function createServerSession(
? (() => {
const incoming = new Map(page.source.map((message) => [message.id, message]))
const existing = data.session_message[sessionID] ?? []
const current = existing.filter((message) => !incoming.has(message.id))
const boundary = Math.min(...page.source.map((message) => message.time.created))
const current = existing.filter(
(message) =>
!incoming.has(message.id) &&
(page.sourceMode === "older" ||
load?.touchedSource.has(message.id) ||
(!page.complete && message.time.created < boundary)),
)
const live = new Map(existing.map((message) => [message.id, message]))
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
@@ -1382,14 +1393,16 @@ export function createServerSession(
touch(sessionID)
if (data.todo[sessionID] !== undefined && !request?.force) return
if ((await options?.protocol) === "v2") {
// TODO: Restore todos when the V2 API exposes a session todo snapshot.
setData("todo", sessionID, [])
return
}
return runInflight(inflightTodo, sessionID, () => {
const active = generation(sessionID)
return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => {
if (!options?.legacy) return Promise.resolve()
return (options.retry ?? retry)(() => options.legacy!.session.todo(sessionID)).then((result) => {
if (generations.get(sessionID) !== active) return
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
setData("todo", sessionID, reconcile(result, { key: "id" }))
})
})
},
+58 -91
View File
@@ -5,7 +5,6 @@ import type {
ProviderAuthResponse,
SessionStatus,
} from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@@ -27,7 +26,7 @@ import {
} from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load"
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
@@ -59,6 +58,8 @@ import type {
} from "@opencode-ai/client/promise"
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession, type ServerSession } from "./server-session"
import { usePlatform } from "./platform"
import type { LegacyCapabilities } from "@/utils/server-compat"
type GlobalStore = {
ready: boolean
@@ -94,8 +95,6 @@ export const loadMcpQuery = (
scope: ServerScope,
directory: string,
api: McpListApi,
legacy?: OpencodeClient,
protocol?: Promise<"v1" | "v2">,
): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
queryOptions<
Record<string, McpServer["status"]>,
@@ -105,7 +104,6 @@ export const loadMcpQuery = (
>({
queryKey: [scope, directory, "mcp"] as const,
queryFn: async () => {
if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {}
return api
.list({ location: { directory } })
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
@@ -116,8 +114,6 @@ export const loadMcpResourcesQuery = (
scope: ServerScope,
directory: string,
api: McpResourceApi,
legacy?: OpencodeClient,
protocol?: Promise<"v1" | "v2">,
): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
queryOptions<
Record<string, McpResource>,
@@ -127,14 +123,6 @@ export const loadMcpResourcesQuery = (
>({
queryKey: [scope, directory, "mcpResources"] as const,
queryFn: async () => {
if ((await protocol) === "v1" && legacy) {
return Object.fromEntries(
Object.entries((await legacy.experimental.resource.list()).data ?? {}).map(([key, resource]) => [
key,
{ ...resource, server: resource.client },
]),
)
}
return api.resource
.catalog({ location: { directory } })
.then((result) =>
@@ -144,10 +132,11 @@ export const loadMcpResourcesQuery = (
placeholderData: {},
})
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
export const loadLspQuery = (scope: ServerScope, directory: string, legacy: LegacyCapabilities, enabled = true) =>
queryOptions({
queryKey: [scope, directory, "lsp"] as const,
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
queryFn: () => legacy.lsp.status(directory),
enabled,
})
export const loadActiveSessionsQuery = (
@@ -178,25 +167,20 @@ export function seedActiveSessionStatuses(
function makeQueryOptionsApi(
scope: ServerScope,
serverSDK: () => OpencodeClient,
serverAPI: ServerApi,
sdkFor: (dir: PathKey) => OpencodeClient,
protocol: Promise<"v1" | "v2">,
protocolKind: Accessor<"v1" | "v2" | undefined>,
legacy: LegacyCapabilities,
) {
return {
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
globalConfig: () => loadGlobalConfigQuery(scope, legacy, protocolKind() === "v1"),
projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
path: (directory: PathKey | null) =>
loadPathQuery(scope, directory, directory ? sdkFor(directory) : serverSDK(), protocol),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol),
references: (directory: PathKey) =>
loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
mcpResources: (directory: PathKey) =>
loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, legacy, protocolKind() === "v1"),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
}
}
@@ -204,35 +188,28 @@ export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
export function createServerSyncContextInner(serverSDK: ServerSDK) {
const language = useLanguage()
const platform = usePlatform()
const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner")
const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
const sdkFor = (directory: string) => {
const key = directoryKey(directory)
const cached = sdkCache.get(key)
if (cached) return cached
const sdk = serverSDK.createClient({
directory,
throwOnError: true,
})
sdkCache.set(key, sdk)
return sdk
}
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
protocol: serverSDK.protocol,
})
const session = createServerSession(
{ session: serverSDK.legacy.session },
serverSDK.currentApi.session,
serverSDK.currentApi.message,
{
protocol: serverSDK.protocol,
legacy: serverSDK.legacy,
},
)
const queryOptionsApi = makeQueryOptionsApi(
serverSDK.scope,
() => serverSDK.client,
serverSDK.api,
sdkFor,
serverSDK.protocol,
serverSDK.currentApi,
serverSDK.protocolKind,
serverSDK.legacy,
)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
@@ -241,19 +218,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const activeSessionsQuery = useQuery(() =>
loadActiveSessionsQuery(serverSDK.scope, {
active: async () => {
if ((await serverSDK.protocol) === "v1") {
const statuses = (await serverSDK.client.session.status()).data ?? {}
seedActiveSessionStatuses(session, statuses)
for (const sessionID of Object.keys(statuses)) {
void session.resolve(sessionID).catch(() => undefined)
}
return Object.fromEntries(
Object.entries(statuses).flatMap(([sessionID, status]) =>
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
),
)
}
const active = await serverSDK.api.session.active()
const active = await serverSDK.currentApi.session.active()
seedActiveSessionStatuses(session, active)
for (const sessionID of Object.keys(active)) {
void session.resolve(sessionID).catch(() => undefined)
@@ -321,8 +286,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
serverSDK: serverSDK.client,
serverAPI: serverSDK.api,
legacy: serverSDK.legacy,
serverAPI: serverSDK.currentApi,
protocol: serverSDK.protocol,
scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"),
@@ -363,7 +328,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
void bootstrapInstance(directory)
},
onMcp: (directory, setStore) => {
void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol)
void loadCommands(directory, serverSDK.currentApi.command)
.then((commands) => setStore("command", commands))
.catch((err) => {
showToast({
@@ -377,7 +342,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const key = directoryKey(directory)
queue.clear(key)
sessionMeta.delete(key)
sdkCache.delete(key)
clearProviderRev(serverSDK.scope, key)
},
translate: language.t,
@@ -416,12 +380,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
.fetchQuery({
...queryOptionsApi.sessions(key),
queryFn: () =>
serverSDK.protocol
.then((protocol) =>
protocol === "v1"
? loadRootSessionsV1({ client: sdkFor(directory), directory, limit })
: loadRootSessions({ api: serverSDK.api.session, directory, limit }),
)
loadRootSessions({ api: serverSDK.currentApi.session, directory, limit })
.then((x) => {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
@@ -479,7 +438,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const child = children.ensureChild(directory)
const cache = children.vcsCache.get(key)
if (!cache) return
const sdk = sdkFor(directory)
await bootstrapDirectory({
directory,
scope: serverSDK.scope,
@@ -490,8 +448,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
project: globalStore.project,
provider: globalStore.provider,
},
sdk,
api: serverSDK.api,
legacy: serverSDK.legacy,
api: serverSDK.currentApi,
store: child[0],
setStore: child[1],
vcsCache: cache,
@@ -610,6 +568,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
permission: session.data.permission,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (serverSDK.protocolKind() !== "v1") return
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
@@ -658,7 +617,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
const updateConfigMutation = useMutation(() => ({
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
mutationFn: (config: Config) => serverSDK.legacy.config.update(config),
onSuccess: () => {
bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers
@@ -692,27 +651,35 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
const sdk = sdkFor(key)
const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status
if (!status) return
await toggleMcp({
status,
connect: async () => {
if ((await serverSDK.protocol) === "v1") {
await sdk.mcp.connect({ name })
return
}
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
await serverSDK.currentApi.mcp.connect({ server: name, location: { directory: key } })
},
disconnect: async () => {
if ((await serverSDK.protocol) === "v1") {
await sdk.mcp.disconnect({ name })
return
}
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
await serverSDK.currentApi.mcp.disconnect({ server: name, location: { directory: key } })
},
authenticate: async () => {
await sdk.mcp.auth.authenticate({ name })
const server = (await serverSDK.currentApi.mcp.list({ location: { directory: key } })).data.find(
(item) => item.name === name,
)
if (!server?.integrationID) throw new Error(`MCP server ${name} has no authentication integration`)
const integration = await serverSDK.currentApi.integration.get({
integrationID: server.integrationID,
location: { directory: key },
})
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.prompts?.length)
if (!method || method.type !== "oauth")
throw new Error(`MCP server ${name} requires an interactive authentication form`)
const attempt = await serverSDK.currentApi.integration.oauth.connect({
integrationID: server.integrationID,
methodID: method.id,
inputs: {},
location: { directory: key },
})
platform.openLink(attempt.data.url)
},
refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
+17 -37
View File
@@ -248,20 +248,12 @@ function createWorkspaceTerminalSession(
setStore("all", index, (item) => ({ ...item, ...pty }))
}
const doUpdate = async () => {
if ((await sdk.protocol) === "v1") {
await sdk.client.pty.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
} else {
await sdk.api.pty.update({
ptyID: pty.id,
location,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
}
await sdk.currentApi.pty.update({
ptyID: pty.id,
location,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
}
doUpdate().catch((error: unknown) => {
if (previous) {
@@ -276,20 +268,13 @@ function createWorkspaceTerminalSession(
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const data = await (async () => {
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.create({ title: pty.title })).data
}
return (
await sdk.api.pty.create({
location,
title: pty.title,
})
).data
})().catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
const data = await sdk.currentApi.pty
.create({ location, title: pty.title })
.then((result) => result.data)
.catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!data?.id) return
const active = store.active === pty.id
@@ -326,10 +311,9 @@ function createWorkspaceTerminalSession(
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
const doCreate = async () => {
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.create({ title: defaultTitle(nextNumber) })).data
}
return (await sdk.api.pty.create({ location, title: defaultTitle(nextNumber) })).data
return sdk.currentApi.pty
.create({ location, title: defaultTitle(nextNumber) })
.then((result) => result.data)
}
doCreate()
.then((data) => {
@@ -433,11 +417,7 @@ function createWorkspaceTerminalSession(
})
}
const removePromise =
(await sdk.protocol) === "v1"
? sdk.client.pty.remove({ ptyID: id })
: sdk.api.pty.remove({ ptyID: id, location })
await removePromise.catch((error: unknown) => {
await sdk.currentApi.pty.remove({ ptyID: id, location }).catch((error: unknown) => {
console.error("Failed to close terminal", error)
})
},
@@ -109,6 +109,7 @@ export function createHomeProjectsController(home: HomeController) {
home.server.context(conn).projects.move(worktree, index)
},
canReveal: canRevealProject,
canEdit: (conn: ServerConnection.Any) => home.server.context(conn).sdk.protocolKind() === "v1",
reveal: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.openPath || !canRevealProject(conn)) return
platform.openPath(project.worktree).catch((cause: unknown) =>
@@ -40,6 +40,7 @@ export type HomeProjectsViewProps = {
canDefaultServer: Accessor<boolean>
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
canRevealProject: (server: ServerConnection.Any) => boolean
canEditProject: (server: ServerConnection.Any) => boolean
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
onWheel: (event: WheelEvent) => void
onChooseProject: (server: ServerConnection.Any) => void
@@ -548,9 +549,11 @@ function HomeProjectRow(
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
<Show when={props.canEditProject(props.server)}>
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
</Show>
<Show when={props.canRevealProject(props.server)}>
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
{props.language.t(
@@ -17,6 +17,7 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
canDefaultServer={props.projects.server.canDefault}
defaultServerKey={props.projects.server.defaultKey}
canRevealProject={props.projects.project.canReveal}
canEditProject={props.projects.project.canEdit}
unseenCount={props.projects.project.unseenCount}
onWheel={props.scroll.viewport.containWheel}
onChooseProject={props.projects.project.choose}
@@ -1,4 +1,4 @@
import type { Session } from "@/types"
import type { Session, V2SessionListResponse } from "@/types"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMarked } from "@opencode-ai/ui/context/marked"
@@ -69,7 +69,10 @@ export function createHomeSessionsController(home: HomeController) {
const cache = homeSessions()
const eventSequence = cache.eventSequence()
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.client.v2.session.list(input, options),
(input, options) =>
ctx.sdk.currentApi.session.list(input, options).then((data) => ({
data: data as unknown as V2SessionListResponse,
})),
eventSequence,
signal,
)
@@ -179,6 +182,7 @@ export function createHomeSessionsController(home: HomeController) {
showProjectName: () => !home.project.selected(),
server: () => home.selection.value().server,
canCreate: () => !!home.project.newSession(),
canArchive: () => home.server.focusedContext()?.sdk.protocolKind() === "v1",
create: home.project.openNewSession,
open: (session: Session, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.directory)
@@ -211,16 +215,10 @@ export function createHomeSessionsController(home: HomeController) {
const ctx = home.server.focusedContext()
if (!conn || !ctx) return
const [, setStore] = ctx.sync.child(session.directory)
if ((await ctx.sdk.protocol) !== "v1") return
await archiveHomeSession({
server: ServerConnection.key(conn),
session,
archive: (sessionID) =>
ctx.sdk.client.session.update({
sessionID,
directory: session.directory,
time: { archived: Date.now() },
}),
archive: (sessionID) => ctx.sdk.legacy.session.archive(sessionID, session.directory),
remove: () =>
setStore(
produce((draft) => {
@@ -43,6 +43,7 @@ export type HomeSessionsViewProps = {
showProjectName: Accessor<boolean>
server: Accessor<ServerConnection.Key>
canCreateSession: Accessor<boolean>
canArchiveSession: Accessor<boolean>
searchValue: Accessor<string>
searchPlaceholder: Accessor<string>
searchOpen: Accessor<boolean>
@@ -460,7 +461,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
group-hover/session:opacity-100 focus-within:opacity-100
`}
>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<Show when={props.canArchiveSession()}>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<IconButtonV2
data-action="home-session-archive"
variant="ghost-muted"
@@ -473,7 +475,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
void props.onArchiveSession(props.record.session)
}}
/>
</TooltipV2>
</TooltipV2>
</Show>
</div>
</Show>
</div>
@@ -16,6 +16,7 @@ export function HomeSessions(props: {
showProjectName={props.sessions.session.showProjectName}
server={props.sessions.session.server}
canCreateSession={props.sessions.session.canCreate}
canArchiveSession={props.sessions.session.canArchive}
searchValue={props.search.query.value}
searchPlaceholder={props.search.query.placeholder}
searchOpen={props.search.query.open}
+68 -64
View File
@@ -25,7 +25,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/core/util/path"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import type { Session } from "@/types"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
@@ -872,17 +872,12 @@ export default function LegacyLayout(props: ParentProps) {
}
async function archiveSession(session: Session) {
if ((await serverSDK().protocol) !== "v1") return
const [store, setStore] = serverSync().child(session.directory)
const sessions = store.session ?? []
const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1]
await serverSDK().client.session.update({
sessionID: session.id,
directory: session.directory,
time: { archived: Date.now() },
})
await serverSDK().legacy.session.archive(session.id, session.directory)
setStore(
produce((draft) => {
const match = Binary.search(draft.session, session.id, (s) => s.id)
@@ -980,6 +975,7 @@ export default function LegacyLayout(props: ParentProps) {
title: language.t("command.session.archive"),
category: language.t("command.category.session"),
keybind: "mod+shift+backspace",
hidden: serverSDK().protocolKind() !== "v1",
disabled: !params.dir || !params.id,
onSelect: () => {
const session = currentSessions().find((s) => s.id === params.id)
@@ -1189,10 +1185,13 @@ export default function LegacyLayout(props: ParentProps) {
const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target)
const listed = await Promise.resolve(
project?.id ?? serverSDK().api.project.current({ location: { directory: root } }),
project?.id ?? serverSDK().currentApi.project.current({ location: { directory: root } }),
)
.then((value) => (typeof value === "string" ? value : value.id))
.then((projectID) => serverSDK().api.project.directories({ projectID, location: { directory: root } }))
.then(async (projectID) => {
await serverSDK().currentApi.projectCopy.refresh({ projectID, location: { directory: root } })
return serverSDK().currentApi.project.directories({ projectID, location: { directory: root } })
})
.then((items) => items.map((item) => item.directory).filter((item) => pathKey(item) !== pathKey(root)))
.catch(() => [] as string[])
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
@@ -1237,7 +1236,7 @@ export default function LegacyLayout(props: ParentProps) {
await Promise.all(
dirs.map(async (item) => ({
path: { directory: item },
session: await listAllSessions(serverSDK().api.session, {
session: await listAllSessions(serverSDK().currentApi.session, {
directory: item,
parentID: null,
order: "desc",
@@ -1301,13 +1300,10 @@ export default function LegacyLayout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next
if (project.id && project.id !== "global") {
const sdk = serverSDK()
if ((await sdk.protocol) !== "v1") return
const result = await sdk.client.project
const result = await serverSDK().legacy.project
.update({ projectID: project.id, directory: project.worktree, name })
.then((response) => response.data)
if (!result) return
// const result = await serverSDK().api.project.update({ projectID: project.id, name })
serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
)
@@ -1403,16 +1399,19 @@ export default function LegacyLayout(props: ParentProps) {
setBusy(directory, true)
const result = await serverSDK()
.client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("workspace.delete.failed.title"),
description: errorMessage(err, language.t("common.requestFailed")),
})
return false
})
const projectID = serverSync().data.project.find((project) => project.worktree === root)?.id
const result = projectID
? await serverSDK()
.currentApi.projectCopy.remove({ projectID, directory, force: false, location: { directory: root } })
.then(() => true)
.catch((err) => {
showToast({
title: language.t("workspace.delete.failed.title"),
description: errorMessage(err, language.t("common.requestFailed")),
})
return false
})
: false
setBusy(directory, false)
@@ -1461,7 +1460,9 @@ export default function LegacyLayout(props: ParentProps) {
})
const dismiss = () => toaster.dismiss(progress)
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
const sessions = await listAllSessions(serverSDK().currentApi.session, { directory, order: "desc" }).catch(
() => [],
)
clearWorkspaceTerminals(
directory,
@@ -1469,12 +1470,8 @@ export default function LegacyLayout(props: ParentProps) {
platform,
serverSDK().scope,
)
await serverSDK()
.client.instance.dispose({ directory })
.catch(() => undefined)
const result = await serverSDK()
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
.legacy.workspace.reset(root, directory)
.then((x) => x.data)
.catch((err) => {
showToast({
@@ -1496,11 +1493,7 @@ export default function LegacyLayout(props: ParentProps) {
.filter((session) => session.time.archived === undefined)
.map((session) =>
serverSDK()
.client.session.update({
sessionID: session.id,
directory: session.directory,
time: { archived: Date.now() },
})
.legacy.session.archive(session.id, session.directory)
.catch(() => undefined),
),
)
@@ -1595,7 +1588,7 @@ export default function LegacyLayout(props: ParentProps) {
})
const refresh = async () => {
const sessions = await listAllSessions(serverSDK().api.session, {
const sessions = await listAllSessions(serverSDK().currentApi.session, {
directory: props.directory,
order: "desc",
}).catch(() => [])
@@ -1835,20 +1828,26 @@ export default function LegacyLayout(props: ParentProps) {
const createWorkspace = async (project: LocalProject) => {
clearSidebarHoverState()
const created = await serverSDK()
.client.worktree.create({ directory: project.worktree })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("workspace.create.failed.title"),
description: errorMessage(err, language.t("common.requestFailed")),
})
return undefined
})
const created = project.id
? await serverSDK()
.currentApi.projectCopy.create({
projectID: project.id,
strategy: "git_worktree",
directory: getDirectory(project.worktree),
location: { directory: project.worktree },
})
.catch((err) => {
showToast({
title: language.t("workspace.create.failed.title"),
description: errorMessage(err, language.t("common.requestFailed")),
})
return undefined
})
: undefined
if (!created?.directory) return
setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch)
setWorkspaceName(created.directory, getFilename(created.directory), project.id)
const local = project.worktree
const key = pathKey(created.directory)
@@ -1881,6 +1880,8 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon,
prefetchSession,
archiveSession,
canArchive: () => serverSDK().protocolKind() === "v1",
canResetWorkspace: () => serverSDK().protocolKind() === "v1",
workspaceName,
renameWorkspace,
editorOpen,
@@ -1917,6 +1918,7 @@ export default function LegacyLayout(props: ParentProps) {
openSidebar: () => layout.sidebar.open(),
closeProject,
showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
canEditProject: () => serverSDK().protocolKind() === "v1",
toggleProjectWorkspaces,
workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
workspaceIds,
@@ -1927,6 +1929,7 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon,
prefetchSession,
archiveSession,
canArchive: () => serverSDK().protocolKind() === "v1",
},
}
@@ -2017,16 +2020,19 @@ export default function LegacyLayout(props: ParentProps) {
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => {
void renameProject(project, next)
}}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
<Show
when={serverSDK().protocolKind() === "v1" || !project.id || project.id === "global"}
fallback={<span class="text-14-medium text-text-strong truncate">{projectName()}</span>}
>
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => void renameProject(project, next)}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
</Show>
<Tooltip
placement="bottom"
@@ -2061,13 +2067,11 @@ export default function LegacyLayout(props: ParentProps) {
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
showEditProjectDialog(server.current!, project)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={serverSDK().protocolKind() === "v1"}>
<DropdownMenu.Item onSelect={() => showEditProjectDialog(server.current!, project)}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
@@ -87,6 +87,7 @@ export type SessionItemProps = {
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
}
const SessionRow = (props: {
@@ -241,7 +242,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
</Show>
</div>
<Show when={!props.level}>
<Show when={!props.level && props.canArchive()}>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
@@ -27,6 +27,7 @@ export type ProjectSidebarContext = {
openSidebar: () => void
closeProject: (directory: string) => void
showEditProjectDialog: (project: LocalProject) => void
canEditProject: Accessor<boolean>
toggleProjectWorkspaces: (project: LocalProject) => void
workspacesEnabled: (project: LocalProject) => boolean
workspaceIds: (project: LocalProject) => string[]
@@ -65,6 +66,7 @@ const ProjectTile = (props: {
onProjectFocus: (worktree: string) => void
navigateToProject: (directory: string) => void
showEditProjectDialog: (project: LocalProject) => void
canEditProject: Accessor<boolean>
toggleProjectWorkspaces: (project: LocalProject) => void
workspacesEnabled: (project: LocalProject) => boolean
closeProject: (directory: string) => void
@@ -148,9 +150,11 @@ const ProjectTile = (props: {
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<Show when={props.canEditProject()}>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</Show>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
@@ -331,6 +335,7 @@ export const SortableProject = (props: {
onProjectFocus={props.ctx.onProjectFocus}
navigateToProject={props.ctx.navigateToProject}
showEditProjectDialog={props.ctx.showEditProjectDialog}
canEditProject={props.ctx.canEditProject}
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
workspacesEnabled={props.ctx.workspacesEnabled}
closeProject={props.ctx.closeProject}
@@ -42,6 +42,8 @@ export type WorkspaceSidebarContext = {
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
canResetWorkspace: Accessor<boolean>
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
editorOpen: (id: string) => boolean
@@ -151,6 +153,7 @@ const WorkspaceActions = (props: {
workspaceValue: Accessor<string>
openEditor: WorkspaceSidebarContext["openEditor"]
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
canResetWorkspace: WorkspaceSidebarContext["canResetWorkspace"]
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
root: string
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
@@ -199,12 +202,14 @@ const WorkspaceActions = (props: {
>
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={props.local() || props.busy()}
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
>
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={props.canResetWorkspace()}>
<DropdownMenu.Item
disabled={props.local() || props.busy()}
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
>
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item
disabled={props.local() || props.busy()}
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
@@ -272,6 +277,7 @@ const WorkspaceSessionList = (props: {
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession}
canArchive={props.ctx.canArchive}
/>
)}
</For>
@@ -416,6 +422,7 @@ export const SortableWorkspace = (props: {
workspaceValue={workspaceValue}
openEditor={props.ctx.openEditor}
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
canResetWorkspace={props.ctx.canResetWorkspace}
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
root={props.project.worktree}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+19 -13
View File
@@ -52,7 +52,7 @@ import { useNotification } from "@/context/notification"
import { PromptProvider, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { SDKProvider, useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { ServerConnection, serverName, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
@@ -361,6 +361,7 @@ export default function Page() {
const language = useLanguage()
const sdk = useSDK()
const serverSDK = useServerSDK()
const protocol = useServerProtocol()
const settings = useSettings()
const platform = usePlatform()
const prompt = usePrompt()
@@ -847,7 +848,7 @@ export default function Page() {
}
const gitMutation = useMutation(() => ({
mutationFn: () => sdk().client.project.initGit(),
mutationFn: () => sdk().legacy.project.initGit(sdk().directory),
onSuccess: (x) => {
if (!x.data) return
upsert(x.data)
@@ -896,17 +897,19 @@ export default function Page() {
() => {
const id = params.id
return [
protocol(),
sdk().directory,
id,
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
id ? composer.blocked() : false,
] as const
},
([dir, id, status, blocked]) => {
([serverProtocol, dir, id, status, blocked]) => {
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
todoFrame = undefined
todoTimer = undefined
if (serverProtocol !== "v1") return
if (!id) return
if (status === "idle" && !blocked) return
const cached = untrack(() => sync().data.todo[id] !== undefined)
@@ -1217,11 +1220,13 @@ export default function Page() {
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
<Show when={protocol() === "v1"}>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
</Show>
</div>
)
@@ -1254,7 +1259,8 @@ export default function Page() {
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
}
if (reviewMode() === "turn" && nogit()) {
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
if (protocol() === "v1") return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
return empty(language.t("session.review.noVcs.createGit.description"))
}
return <SessionReviewEmptyChangesV2 />
}
@@ -1724,7 +1730,7 @@ export default function Page() {
setFollowup("failed", input.sessionID, undefined)
const ok = await sendFollowupDraft({
api: sdk().api.session,
api: sdk().currentApi.session,
sync: sync(),
serverSync: serverSync(),
draft: item,
@@ -1820,13 +1826,13 @@ export default function Page() {
const halt = (sessionID: string) =>
busy(sessionID)
? sdk()
.api.session.interrupt({ sessionID })
.currentApi.session.interrupt({ sessionID })
.catch(() => {})
: Promise.resolve()
const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
const session = sdk().api.session
const session = sdk().currentApi.session
const target = sync()
const last = target.session.get(input.sessionID)?.revert
const value = draft(input.messageID)
@@ -1849,7 +1855,7 @@ export default function Page() {
const sessionID = params.id
if (!sessionID) return
const session = sdk().api.session
const session = sdk().currentApi.session
const target = sync()
const next = userMessages().find((item) => item.id > id)
const last = target.session.get(sessionID)?.revert
@@ -55,8 +55,8 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
const readFile = async (path: string) => {
return sdk()
.client.file.read({ path })
.then((x) => x.data)
.currentApi.file.read({ path, location: { directory: sdk().directory } })
.then((content) => ({ type: "text" as const, content: new TextDecoder().decode(content) }))
.catch((error) => {
console.debug("[session-review] failed to read file", { path, error })
return undefined
@@ -62,7 +62,7 @@ import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
@@ -302,7 +302,8 @@ export function MessageTimeline(props: {
return displayLabel(session)
})
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const protocol = useServerProtocol()
const shareEnabled = createMemo(() => protocol() === "v1" && sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
@@ -665,14 +666,14 @@ export function MessageTimeline(props: {
}
const shareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }),
mutationFn: (id: string) => serverSDK().legacy.session.share(id),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }),
mutationFn: (id: string) => serverSDK().legacy.session.unshare(id),
onError: (err) => {
console.error("Failed to unshare session", err)
},
@@ -680,7 +681,7 @@ export function MessageTimeline(props: {
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk().api.session.rename({ sessionID: input.id, title: input.title }),
sdk().currentApi.session.rename({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync().set(
produce((draft) => {
@@ -818,14 +819,12 @@ export function MessageTimeline(props: {
const archiveSession = async (sessionID: string) => {
const session = sync().session.get(sessionID)
if (!session) return
if ((await sdk().protocol) !== "v1") return
const sessions = sync().data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk()
.client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } })
.legacy.session.archive(sessionID, sdk().directory)
.then(() => {
sync().set(
produce((draft) => {
@@ -854,7 +853,7 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk()
.api.session.remove({ sessionID })
.currentApi.session.remove({ sessionID })
.then(() => true)
.catch((err) => {
showToast({
@@ -1574,9 +1573,11 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={protocol() === "v1"}>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
@@ -1645,9 +1646,11 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
<Show when={protocol() === "v1"}>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
{language.t("common.delete")}...
@@ -19,6 +19,7 @@ import type { UserMessage } from "@/types"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useLocal } from "@/context/local"
import { useServerProtocol } from "@/context/server-sdk"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
@@ -43,6 +44,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const permission = usePermission()
const prompt = usePrompt()
const sdk = useSDK()
const protocol = useServerProtocol()
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
@@ -194,7 +196,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const url = await sdk()
.client.session.share({ sessionID })
.legacy.session.share(sessionID)
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
@@ -214,7 +216,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!sessionID) return
await sdk()
.client.session.unshare({ sessionID })
.legacy.session.unshare(sessionID)
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
@@ -306,7 +308,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = params.id
if (!sessionID) return
const owner = sessionOwnership.capture()
const session = sdk().api.session
const session = sdk().currentApi.session
const directory = sdk().directory
const promptSession = prompt.capture()
const revert = info()?.revert?.messageID
@@ -334,7 +336,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = params.id
if (!sessionID) return
const owner = sessionOwnership.capture()
const session = sdk().api.session
const session = sdk().currentApi.session
const messages = userMessages()
const promptSession = prompt.capture()
@@ -366,19 +368,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
})
return
}
await sdk().api.session.compact({
sessionID,
model: { providerID: model.provider.id, modelID: model.id },
})
await sdk().currentApi.session.compact({ sessionID })
}
const fork = () => {
@@ -389,6 +379,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const shareCmds = () => {
if (protocol() !== "v1") return []
if (sync().data.config.share === "disabled") return []
return [
sessionCommand({
@@ -102,8 +102,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
const readFile = async (path: string) =>
sdk()
.client.file.read({ path })
.then((x) => x.data)
.currentApi.file.read({ path, location: { directory: sdk().directory } })
.then((content) => ({ type: "text" as const, content: new TextDecoder().decode(content) }))
.catch((error) => {
console.debug("[session-review-v2] failed to read file", { path, error })
return undefined
+134 -34
View File
@@ -3,7 +3,6 @@ import type { ServerProtocol } from "./server-protocol"
import type { AgentPartInput, FilePartInput, Session, TextPartInput } from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type {
Project,
ProjectCurrent,
SessionApi,
SessionCommandInput,
@@ -28,7 +27,6 @@ type CompatibleSessionApi = Omit<
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
// archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
}
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
@@ -54,6 +52,99 @@ type CompatibleInput = {
directory?: string
}
export function createLegacyCapabilities(input: CompatibleInput) {
const directory = (value?: string) => value ?? input.directory
const client = (value?: string) => input.legacy(directory(value))
const requireV1 = async () => {
if ((await input.protocol) !== "v1") throw new Error("This capability is unavailable on V2 servers")
}
return {
config: {
global: async () => {
await requireV1()
return (await client().global.config.get()).data ?? {}
},
directory: async (value?: string) => {
await requireV1()
return (await client(value).config.get()).data ?? {}
},
update: async (config: NonNullable<Parameters<LegacyClient["global"]["config"]["update"]>[0]>["config"]) => {
await requireV1()
return client().global.config.update({ config })
},
},
auth: {
set: async (value: Parameters<LegacyClient["auth"]["set"]>[0]) => {
await requireV1()
return client().auth.set(value)
},
remove: async (value: Parameters<LegacyClient["auth"]["remove"]>[0]) => {
await requireV1()
return client().auth.remove(value)
},
},
session: {
get: (value: Parameters<LegacyClient["session"]["get"]>[0]) => client().session.get(value),
messages: (value: Parameters<LegacyClient["session"]["messages"]>[0]) => client().session.messages(value),
message: (value: Parameters<LegacyClient["session"]["message"]>[0]) => client().session.message(value),
share: async (sessionID: string) => {
await requireV1()
return client().session.share({ sessionID })
},
unshare: async (sessionID: string) => {
await requireV1()
return client().session.unshare({ sessionID })
},
archive: async (sessionID: string, value?: string) => {
await requireV1()
return client(value).session.update({ sessionID, time: { archived: Date.now() } })
},
todo: async (sessionID: string, value?: string) => {
await requireV1()
return (await client(value).session.todo({ sessionID })).data ?? []
},
},
project: {
update: async (value: Parameters<LegacyClient["project"]["update"]>[0]) => {
await requireV1()
return client(value.directory).project.update(value)
},
initGit: async (value?: string) => {
await requireV1()
return client(value).project.initGit()
},
},
workspace: {
reset: async (root: string, value: string) => {
await requireV1()
await client(value).instance.dispose().catch(() => undefined)
return client(root).worktree.reset({ worktreeResetInput: { directory: value } })
},
},
pty: {
shells: async () => {
await requireV1()
return (await client().pty.shells()).data ?? []
},
},
path: {
get: async (value?: string) => {
await requireV1()
return (await client(value).path.get()).data
},
},
lsp: {
status: async (value: string) => {
await requireV1()
return (await client(value).lsp.status()).data ?? []
},
},
}
}
export type LegacyCapabilities = ReturnType<typeof createLegacyCapabilities>
function mime(uri: string) {
const match = /^data:([^;,]+)/.exec(uri)
return match?.[1] ?? "application/octet-stream"
@@ -184,9 +275,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
},
// async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
// await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
// },
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
await legacy(value).session.delete(value)
},
@@ -313,34 +401,28 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
canonical: result.data.worktree,
} satisfies ProjectCurrent
},
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
// const result = await legacy({ directory: project?.worktree }).project.update({
// ...value,
// directory: project?.worktree,
// })
// if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
// return result.data as Project
// },
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
const result = await legacy(value.location).worktree.list()
return (result.data ?? []).map((item) => ({ directory: item }))
},
},
// path: {
// ...input.current.path,
// async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
// const result = await legacy(value?.location).path.get()
// if (!result.data) throw new Error("Path unavailable")
// return result.data
// },
// },
location: {
...input.current.location,
async get(value?: Parameters<ServerApi["location"]["get"]>[0]) {
const result = await legacy(value?.location).path.get()
if (!result.data) throw new Error("Location unavailable")
return {
directory: result.data.directory,
project: {
id: "",
directory: result.data.worktree,
canonical: result.data.worktree,
},
}
},
},
vcs: {
...input.current.vcs,
// async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
// const result = await legacy(value?.location).vcs.get()
// return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
// },
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
const result = await legacy(value?.location).vcs.status()
return located(result.data ?? [], value?.location)
@@ -456,9 +538,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
},
pty: {
...input.current.pty,
// async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
// return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
// },
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
},
@@ -490,14 +569,29 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
},
// async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
// const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
// if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
// return located(result.data, value.location)
// },
},
permission: {
...input.current.permission,
request: {
...input.current.permission.request,
async list(value?: Parameters<ServerApi["permission"]["request"]["list"]>[0]) {
const result = await legacy(value?.location).permission.list()
return located(
(result.data ?? []).map((item) => ({
id: item.id,
sessionID: item.sessionID,
action: item.permission,
resources: item.patterns,
metadata: item.metadata,
save: item.always,
source: item.tool
? { type: "tool" as const, messageID: item.tool.messageID, callID: item.tool.callID }
: undefined,
})),
value?.location,
) as Awaited<ReturnType<ServerApi["permission"]["request"]["list"]>>
},
},
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } }) {
await legacy(value.location).permission.respond({
sessionID: value.sessionID,
@@ -509,6 +603,12 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
},
question: {
...input.current.question,
request: {
...input.current.question.request,
async list(value?: Parameters<ServerApi["question"]["request"]["list"]>[0]) {
return located((await legacy(value?.location).question.list()).data ?? [], value?.location)
},
},
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
await legacy().question.reply({
requestID: value.requestID,
@@ -3,6 +3,31 @@ import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { normalizeSessionMessages } from "./session-message"
describe("normalizeSessionMessages", () => {
test("keeps attachments without inventing an empty text part", () => {
const source = [
{
id: "msg_1",
type: "user",
text: "",
files: [
{
data: "aGVsbG8=",
mime: "text/plain",
name: "note.txt",
source: { type: "inline" },
},
],
agents: [{ name: "review" }],
time: { created: 1 },
},
] satisfies SessionMessageInfo[]
const result = normalizeSessionMessages("ses_1", source)
expect(result.messages).toHaveLength(1)
expect(result.parts.get("msg_1")?.map((part) => part.type)).toEqual(["file", "agent"])
})
test("projects current turns into stable legacy rendering records", () => {
const source = [
{ id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } },
+1 -1
View File
@@ -196,7 +196,7 @@ function userMessage(
function userParts(sessionID: string, message: SessionMessageUser): Part[] {
return [
textPart(sessionID, message.id, 0, message.text),
...(message.text ? [textPart(sessionID, message.id, 0, message.text)] : []),
...(message.files ?? []).map(
(file, index): FilePart => ({
id: `${message.id}:file:${index}`,
+1 -1
View File
@@ -61,7 +61,7 @@ for (const item of targets) {
name: "parcel-watcher-binding",
setup(build) {
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
contents: `export default () => require(${JSON.stringify(parcelWatcherPackage)})`,
contents: `import binding from ${JSON.stringify(parcelWatcherPackage)}; export default () => binding`,
loader: "js",
}))
},
+6 -3
View File
@@ -8,7 +8,7 @@ import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
import { Effect, FileSystem, Option, Redacted, Schedule, Schema } from "effect"
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
@@ -81,7 +81,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
database: {
path:
process.env.OPENCODE_DB ??
(["latest", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
(["latest", "beta", "prod"].includes(OPENCODE_CHANNEL) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
@@ -108,7 +108,9 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
gitbash: process.env.OPENCODE_GIT_BASH_PATH,
},
fs: {
filewatcher: !truthy(process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER),
filewatcher: !truthy(
process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER,
),
fff:
process.env.OPENCODE_DISABLE_FFF === undefined
? process.platform !== "win32"
@@ -126,6 +128,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
}),
},
).pipe(
Effect.provide(Logger.layer([], { mergeWithExisting: false })),
Effect.catch((error) => {
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
+6 -41
View File
@@ -283,26 +283,6 @@ export type Endpoint5_26Input = {
}
export type Endpoint5_26Output =
| (
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.created"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly projectID: Project.ID
readonly location: Location.Ref
readonly subpath?: RelativePath | undefined
readonly parentID?: Session.ID | undefined
readonly slug: string
readonly title?: string | undefined
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly version: string
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -1558,33 +1538,19 @@ export interface DebugApi<E = never> {
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
}
export type Endpoint27_0Output = {
readonly status: "required" | "running" | "completed"
readonly completed: number
readonly total: number
}
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
export type Endpoint27_1Output = { readonly status: "completed" }
export type MigrationV1RunOperation<E = never> = () => Effect.Effect<Endpoint27_1Output, E>
export interface MigrationApi<E = never> {
readonly v1: { readonly status: MigrationV1StatusOperation<E>; readonly run: MigrationV1RunOperation<E> }
}
export type Endpoint28_0Input = {
export type Endpoint27_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
export type Endpoint28_1Input = {
export type Endpoint27_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly providerID?: WebSearch.ID | undefined
}
export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
export type WebsearchQueryOperation<E = never> = (input: Endpoint28_1Input) => Effect.Effect<Endpoint28_1Output, E>
export type Endpoint27_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
export interface WebsearchApi<E = never> {
readonly providers: WebsearchProvidersOperation<E>
@@ -1619,6 +1585,5 @@ export interface AppApi<E = never> {
readonly projectCopy: ProjectCopyApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
readonly migration: MigrationApi<E>
readonly websearch: WebsearchApi<E>
}
+10 -23
View File
@@ -215,12 +215,10 @@ import type {
Endpoint26_0Output,
Endpoint26_1Input,
Endpoint26_1Output,
Endpoint27_0Input,
Endpoint27_0Output,
Endpoint27_1Input,
Endpoint27_1Output,
Endpoint28_0Input,
Endpoint28_0Output,
Endpoint28_1Input,
Endpoint28_1Output,
} from "../api/api.js"
import { ClientError } from "./client-error"
@@ -1219,32 +1217,22 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
})
const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint27_1 = (raw: RawClient["server.migration"]) => () =>
preserveEffect<Endpoint27_1Output>()(raw["migration.v1.run"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({
v1: { status: Endpoint27_0(raw), run: Endpoint27_1(raw) },
})
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
preserveEffect<Endpoint28_0Output>()(
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
preserveEffect<Endpoint27_0Output>()(
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
preserveEffect<Endpoint28_1Output>()(
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
preserveEffect<Endpoint27_1Output>()(
raw["websearch.query"]({
query: { location: input["location"] },
payload: { query: input["query"], providerID: input["providerID"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
providers: Endpoint28_0(raw),
query: Endpoint28_1(raw),
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
providers: Endpoint27_0(raw),
query: Endpoint27_1(raw),
})
const adaptClient = (raw: RawClient) => ({
@@ -1275,8 +1263,7 @@ const adaptClient = (raw: RawClient) => ({
projectCopy: adaptGroup24(raw["server.projectCopy"]),
vcs: adaptGroup25(raw["server.vcs"]),
debug: adaptGroup26(raw["server.debug"]),
migration: adaptGroup27(raw["server.migration"]),
websearch: adaptGroup28(raw["server.websearch"]),
websearch: adaptGroup27(raw["server.websearch"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
@@ -211,8 +211,6 @@ import type {
DebugLocationListOutput,
DebugLocationEvictInput,
DebugLocationEvictOutput,
MigrationV1StatusOutput,
MigrationV1RunOutput,
WebsearchProvidersInput,
WebsearchProvidersOutput,
WebsearchQueryInput,
@@ -494,7 +492,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
successStatus: 200,
declaredStatuses: [404, 401, 400],
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
@@ -720,7 +718,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context`,
successStatus: 200,
declaredStatuses: [404, 500, 401, 400],
declaredStatuses: [404, 500, 400, 401],
empty: false,
},
requestOptions,
@@ -732,7 +730,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending`,
successStatus: 200,
declaredStatuses: [404, 401, 400],
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
@@ -795,7 +793,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/log`,
query: { after: input["after"], follow: input["follow"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
@@ -828,7 +826,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
successStatus: 200,
declaredStatuses: [404, 401, 400],
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
@@ -1770,32 +1768,6 @@ export function make(options: ClientOptions) {
),
},
},
migration: {
v1: {
status: (requestOptions?: RequestOptions) =>
request<MigrationV1StatusOutput>(
{
method: "GET",
path: `/api/experimental/migration/v1`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
run: (requestOptions?: RequestOptions) =>
request<MigrationV1RunOutput>(
{
method: "POST",
path: `/api/experimental/migration/v1`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
},
websearch: {
providers: (input?: WebsearchProvidersInput, requestOptions?: RequestOptions) =>
request<WebsearchProvidersOutput>(
+420 -28
View File
@@ -313,6 +313,164 @@ export type SkillInfo = {
content: string
}
export type FileDiffLegacyInfo = {
file?: string
patch?: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
}
export type PermissionV1Action = "allow" | "deny" | "ask"
export type SessionV1JSONSchema = { [x: string]: any }
export type ProviderAuthError = { name: "ProviderAuthError"; data: { providerID: string; message: string } }
export type UnknownError2 = { name: "UnknownError"; data: { message: string; ref?: string | undefined } }
export type MessageOutputLengthError = { name: "MessageOutputLengthError"; data: {} }
export type MessageAbortedError = { name: "MessageAbortedError"; data: { message: string } }
export type StructuredOutputError = { name: "StructuredOutputError"; data: { message: string; retries: number } }
export type ContextOverflowError = {
name: "ContextOverflowError"
data: { message: string; responseBody?: string | undefined }
}
export type ContentFilterError = { name: "ContentFilterError"; data: { message: string } }
export type APIError = {
name: "APIError"
data: {
message: string
statusCode?: number | undefined
isRetryable: boolean
responseHeaders?: { [x: string]: string } | undefined
responseBody?: string | undefined
metadata?: { [x: string]: string } | undefined
}
}
export type SessionV1TextPart = {
id: string
sessionID: string
messageID: string
type: "text"
text: string
synthetic?: boolean | undefined
ignored?: boolean | undefined
time?: { start: number; end?: number | undefined } | undefined
metadata?: { [x: string]: any } | undefined
}
export type SessionV1SubtaskPart = {
id: string
sessionID: string
messageID: string
type: "subtask"
prompt: string
description: string
agent: string
model?: { providerID: string; modelID: string } | undefined
command?: string | undefined
}
export type SessionV1ReasoningPart = {
id: string
sessionID: string
messageID: string
type: "reasoning"
text: string
metadata?: { [x: string]: any } | undefined
time: { start: number; end?: number | undefined }
}
export type SessionV1FilePartSourceText = { value: string; start: number; end: number }
export type SessionV1Range = { start: { line: number; character: number }; end: { line: number; character: number } }
export type SessionV1ToolStatePending = { status: "pending"; input: { [x: string]: any }; raw: string }
export type SessionV1ToolStateRunning = {
status: "running"
input: { [x: string]: any }
title?: string | undefined
metadata?: { [x: string]: any } | undefined
time: { start: number }
}
export type SessionV1ToolStateError = {
status: "error"
input: { [x: string]: any }
error: string
metadata?: { [x: string]: any } | undefined
time: { start: number; end: number }
}
export type SessionV1StepStartPart = {
id: string
sessionID: string
messageID: string
type: "step-start"
snapshot?: string | undefined
}
export type SessionV1StepFinishPart = {
id: string
sessionID: string
messageID: string
type: "step-finish"
reason: string
snapshot?: string | undefined
cost: number
tokens: {
total?: number | undefined
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
}
export type SessionV1SnapshotPart = {
id: string
sessionID: string
messageID: string
type: "snapshot"
snapshot: string
}
export type SessionV1PatchPart = {
id: string
sessionID: string
messageID: string
type: "patch"
hash: string
files: Array<string>
}
export type SessionV1AgentPart = {
id: string
sessionID: string
messageID: string
type: "agent"
name: string
source?: { value: string; start: number; end: number } | undefined
}
export type SessionV1CompactionPart = {
id: string
sessionID: string
messageID: string
type: "compaction"
auto: boolean
overflow?: boolean | undefined
tail_start_id?: string | undefined
}
export type PermissionReply = "once" | "always" | "reject"
export type Pty = {
@@ -411,27 +569,6 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
projectID: string
location: LocationRef
subpath?: string
parentID?: string
slug: string
title?: string
agent?: string
model?: ModelRef
version: string
}
}
export type SessionAgentSelected = {
id: string
created: number
@@ -725,6 +862,26 @@ export type AgentUpdated = {
data: {}
}
export type MessageRemoved = {
id: string
created: number
metadata?: { [x: string]: any }
type: "message.removed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; messageID: string }
}
export type MessagePartRemoved = {
id: string
created: number
metadata?: { [x: string]: any }
type: "message.part.removed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; messageID: string; partID: string }
}
export type SessionUsageUpdated = {
id: string
created: number
@@ -1380,6 +1537,96 @@ export type PermissionAsked = {
}
}
export type PermissionV1Rule = { permission: string; pattern: string; action: PermissionV1Action }
export type SessionV1OutputFormat =
| { type: "text" }
| { type: "json_schema"; schema: SessionV1JSONSchema; retryCount?: number | undefined | undefined }
export type SessionV1AssistantMessage = {
id: string
sessionID: string
role: "assistant"
time: { created: number; completed?: number | undefined }
error?:
| ProviderAuthError
| UnknownError2
| MessageOutputLengthError
| MessageAbortedError
| StructuredOutputError
| ContextOverflowError
| ContentFilterError
| APIError
| undefined
parentID: string
modelID: string
providerID: string
mode: string
agent: string
path: { cwd: string; root: string }
summary?: boolean | undefined
cost: number
tokens: {
total?: number | undefined
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
structured?: any | undefined
variant?: string | undefined
finish?: string | undefined
}
export type SessionV1RetryPart = {
id: string
sessionID: string
messageID: string
type: "retry"
attempt: number
error: APIError
time: { created: number }
}
export type SessionError = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.error"
location?: LocationRef
data: {
sessionID?: string | undefined
error?:
| ProviderAuthError
| UnknownError2
| MessageOutputLengthError
| MessageAbortedError
| StructuredOutputError
| ContextOverflowError
| ContentFilterError
| APIError
| undefined
}
}
export type SessionV1FileSource = { text: SessionV1FilePartSourceText; type: "file"; path: string }
export type SessionV1ResourceSource = {
text: SessionV1FilePartSourceText
type: "resource"
clientName: string
uri: string
}
export type SessionV1SymbolSource = {
text: SessionV1FilePartSourceText
type: "symbol"
path: string
range: SessionV1Range
name: string
kind: number
}
export type PermissionReplied = {
id: string
created: number
@@ -1657,6 +1904,23 @@ export type FormReplied = {
data: { id: string; sessionID: string; answer: FormAnswer }
}
export type PermissionV1Ruleset = Array<PermissionV1Rule>
export type SessionV1UserMessage = {
id: string
sessionID: string
role: "user"
time: { created: number }
format?: SessionV1OutputFormat | undefined
summary?: { title?: string | undefined; body?: string | undefined; diffs: Array<FileDiffLegacyInfo> } | undefined
agent: string
model: { providerID: string; modelID: string; variant?: string | undefined }
system?: string | undefined
tools?: { [x: string]: boolean } | undefined
}
export type SessionV1FilePartSource = SessionV1FileSource | SessionV1SymbolSource | SessionV1ResourceSource
export type QuestionAsked = {
id: string
created: number
@@ -1734,6 +1998,41 @@ export type IntegrationMethod =
export type FormFields = [FormField, ...Array<FormField>]
export type SessionV1Info = {
id: string
slug: string
projectID: string
workspaceID?: string
directory: string
path?: string
parentID?: string
summary?: { additions: number; deletions: number; files: number; diffs?: Array<FileDiffLegacyInfo> }
cost?: number
tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
share?: { url: string }
title?: string
agent?: string
model?: { id: string; providerID: string; variant?: string }
version: string
metadata?: { [x: string]: any }
time: { created: number; updated: number; compacting?: number; archived?: number }
permission?: PermissionV1Ruleset
revert?: { messageID: string; partID?: string; snapshot?: string; diff?: string }
}
export type SessionV1Message = SessionV1UserMessage | SessionV1AssistantMessage
export type SessionV1FilePart = {
id: string
sessionID: string
messageID: string
type: "file"
mime: string
filename?: string | undefined
url: string
source?: SessionV1FilePartSource | undefined
}
export type FormFields1 = [FormField1, ...Array<FormField1>]
export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction
@@ -1765,6 +2064,56 @@ export type IntegrationInfo = {
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; info: SessionV1Info }
}
export type SessionUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; info: SessionV1Info }
}
export type SessionDeleted1 = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.deleted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; info: SessionV1Info }
}
export type MessageUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "message.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; info: SessionV1Message }
}
export type SessionV1ToolStateCompleted = {
status: "completed"
input: { [x: string]: any }
output: string
title: string
metadata: { [x: string]: any }
time: { start: number; end: number; compacted?: number | undefined }
attachments?: Array<SessionV1FilePart> | undefined
}
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
export type SessionInputAdmitted = {
@@ -1788,6 +2137,12 @@ export type SessionMessageInfo =
| SessionMessageAssistant
| SessionMessageCompaction
export type SessionV1ToolState =
| SessionV1ToolStatePending
| SessionV1ToolStateRunning
| SessionV1ToolStateCompleted
| SessionV1ToolStateError
export type FormCreated = {
id: string
created: number
@@ -1798,7 +2153,6 @@ export type FormCreated = {
}
export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected
| SessionModelSelected
| SessionMoved
@@ -1843,6 +2197,43 @@ export type SessionMessagesResponse = {
cursor: { previous?: string | null; next?: string | null }
}
export type SessionV1ToolPart = {
id: string
sessionID: string
messageID: string
type: "tool"
callID: string
tool: string
state: SessionV1ToolState
metadata?: { [x: string]: any } | undefined
}
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type SessionV1Part =
| SessionV1TextPart
| SessionV1SubtaskPart
| SessionV1ReasoningPart
| SessionV1FilePart
| SessionV1ToolPart
| SessionV1StepStartPart
| SessionV1StepFinishPart
| SessionV1SnapshotPart
| SessionV1PatchPart
| SessionV1AgentPart
| SessionV1RetryPart
| SessionV1CompactionPart
export type MessagePartUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "message.part.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; part: SessionV1Part; time: number }
}
export type V2Event =
| ModelsDevRefreshed
| IntegrationUpdated
@@ -1850,6 +2241,12 @@ export type V2Event =
| CatalogUpdated
| AgentUpdated
| SessionCreated
| SessionUpdated
| SessionDeleted1
| MessageUpdated
| MessageRemoved
| MessagePartUpdated
| MessagePartRemoved
| SessionAgentSelected
| SessionModelSelected
| SessionMoved
@@ -1928,10 +2325,9 @@ export type V2Event =
| VcsBranchUpdated
| McpStatusChanged
| McpResourcesChanged
| SessionError
| V2EventServerConnected
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
@@ -4565,10 +4961,6 @@ export type DebugLocationEvictInput = {
export type DebugLocationEvictOutput = void
export type MigrationV1StatusOutput = { status: "required" | "running" | "completed"; completed: number; total: number }
export type MigrationV1RunOutput = { status: "completed" }
export type WebsearchProvidersInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
-1
View File
@@ -9,7 +9,6 @@
"db": "bun drizzle-kit",
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo --noEmit"
},
+399 -58
View File
@@ -1,15 +1,19 @@
{
"version": "7",
"dialect": "sqlite",
"id": "2d214a71-3b0a-48c1-a667-741952c4e188",
"id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1",
"prevIds": [
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
"db37a97f-9b5e-4c87-be8b-4feace35136c"
],
"ddl": [
{
"name": "workspace",
"entityType": "tables"
},
{
"name": "data_migration",
"entityType": "tables"
},
{
"name": "account_state",
"entityType": "tables"
@@ -62,6 +66,14 @@
"name": "instruction_state",
"entityType": "tables"
},
{
"name": "message",
"entityType": "tables"
},
{
"name": "part",
"entityType": "tables"
},
{
"name": "session_message",
"entityType": "tables"
@@ -71,7 +83,11 @@
"entityType": "tables"
},
{
"name": "session_v2",
"name": "session",
"entityType": "tables"
},
{
"name": "session_share",
"entityType": "tables"
},
{
@@ -154,6 +170,26 @@
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "data_migration"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_completed",
"entityType": "columns",
"table": "data_migration"
},
{
"type": "integer",
"notNull": false,
@@ -498,7 +534,7 @@
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"default": null,
"generated": null,
"name": "created",
"entityType": "columns",
@@ -924,6 +960,116 @@
"entityType": "columns",
"table": "instruction_state"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "message"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "message"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "message_id",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "part"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "part"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": false,
@@ -1072,7 +1218,7 @@
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1082,7 +1228,7 @@
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1092,7 +1238,7 @@
"generated": null,
"name": "workspace_id",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1102,7 +1248,7 @@
"generated": null,
"name": "parent_id",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1112,7 +1258,7 @@
"generated": null,
"name": "fork_session_id",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1122,7 +1268,7 @@
"generated": null,
"name": "fork_boundary",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1132,7 +1278,7 @@
"generated": null,
"name": "slug",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1142,7 +1288,7 @@
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1152,7 +1298,7 @@
"generated": null,
"name": "path",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1162,7 +1308,7 @@
"generated": null,
"name": "title",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1172,7 +1318,7 @@
"generated": null,
"name": "version",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1182,7 +1328,7 @@
"generated": null,
"name": "share_url",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "integer",
@@ -1192,7 +1338,7 @@
"generated": null,
"name": "summary_additions",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "integer",
@@ -1202,7 +1348,7 @@
"generated": null,
"name": "summary_deletions",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "integer",
@@ -1212,7 +1358,7 @@
"generated": null,
"name": "summary_files",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1222,7 +1368,7 @@
"generated": null,
"name": "summary_diffs",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1232,7 +1378,7 @@
"generated": null,
"name": "metadata",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "real",
@@ -1242,7 +1388,7 @@
"generated": null,
"name": "cost",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "integer",
@@ -1252,7 +1398,7 @@
"generated": null,
"name": "tokens_input",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "integer",
@@ -1262,7 +1408,7 @@
"generated": null,
"name": "tokens_output",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "integer",
@@ -1272,7 +1418,7 @@
"generated": null,
"name": "tokens_reasoning",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "integer",
@@ -1282,7 +1428,7 @@
"generated": null,
"name": "tokens_cache_read",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "integer",
@@ -1292,7 +1438,7 @@
"generated": null,
"name": "tokens_cache_write",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1302,7 +1448,7 @@
"generated": null,
"name": "revert",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1312,7 +1458,7 @@
"generated": null,
"name": "permission",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1322,7 +1468,7 @@
"generated": null,
"name": "agent",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
@@ -1332,7 +1478,7 @@
"generated": null,
"name": "model",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "integer",
@@ -1342,7 +1488,7 @@
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "integer",
@@ -1352,7 +1498,7 @@
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "integer",
@@ -1362,7 +1508,7 @@
"generated": null,
"name": "time_compacting",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "integer",
@@ -1372,7 +1518,7 @@
"generated": null,
"name": "time_archived",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "integer",
@@ -1382,7 +1528,67 @@
"generated": null,
"name": "time_suspended",
"entityType": "columns",
"table": "session_v2"
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "secret",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "session_share"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_share"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_share"
},
{
"columns": [
@@ -1463,14 +1669,14 @@
"columns": [
"session_id"
],
"tableTo": "session_v2",
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_instruction_entry_session_id_session_v2_id_fk",
"name": "fk_instruction_entry_session_id_session_id_fk",
"entityType": "fks",
"table": "instruction_entry"
},
@@ -1478,14 +1684,14 @@
"columns": [
"session_id"
],
"tableTo": "session_v2",
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_instruction_state_session_id_session_v2_id_fk",
"name": "fk_instruction_state_session_id_session_id_fk",
"entityType": "fks",
"table": "instruction_state"
},
@@ -1493,14 +1699,44 @@
"columns": [
"session_id"
],
"tableTo": "session_v2",
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_message_session_id_session_v2_id_fk",
"name": "fk_message_session_id_session_id_fk",
"entityType": "fks",
"table": "message"
},
{
"columns": [
"message_id"
],
"tableTo": "message",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_part_message_id_message_id_fk",
"entityType": "fks",
"table": "part"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_message_session_id_session_id_fk",
"entityType": "fks",
"table": "session_message"
},
@@ -1508,14 +1744,14 @@
"columns": [
"session_id"
],
"tableTo": "session_v2",
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_pending_session_id_session_v2_id_fk",
"name": "fk_session_input_session_id_session_id_fk",
"entityType": "fks",
"table": "session_pending"
},
@@ -1530,9 +1766,24 @@
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_v2_project_id_project_id_fk",
"name": "fk_session_project_id_project_id_fk",
"entityType": "fks",
"table": "session_v2"
"table": "session"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_share_session_id_session_id_fk",
"entityType": "fks",
"table": "session_share"
},
{
"columns": [
@@ -1573,6 +1824,15 @@
"table": "workspace",
"entityType": "pks"
},
{
"columns": [
"name"
],
"nameExplicit": false,
"name": "data_migration_pk",
"table": "data_migration",
"entityType": "pks"
},
{
"columns": [
"id"
@@ -1663,6 +1923,24 @@
"table": "instruction_state",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": [
"id"
@@ -1677,7 +1955,7 @@
"id"
],
"nameExplicit": false,
"name": "session_pending_pk",
"name": "session_input_pk",
"table": "session_pending",
"entityType": "pks"
},
@@ -1686,8 +1964,17 @@
"id"
],
"nameExplicit": false,
"name": "session_v2_pk",
"table": "session_v2",
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
"entityType": "pks"
},
{
@@ -1752,6 +2039,60 @@
"entityType": "indexes",
"table": "permission"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
},
{
"value": "time_created",
"isExpression": false
},
{
"value": "id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "message_session_time_created_id_idx",
"entityType": "indexes",
"table": "message"
},
{
"columns": [
{
"value": "message_id",
"isExpression": false
},
{
"value": "id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "part_message_id_id_idx",
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "part_session_idx",
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
@@ -1892,9 +2233,9 @@
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_v2_project_idx",
"name": "session_project_idx",
"entityType": "indexes",
"table": "session_v2"
"table": "session"
},
{
"columns": [
@@ -1906,9 +2247,9 @@
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_v2_workspace_idx",
"name": "session_workspace_idx",
"entityType": "indexes",
"table": "session_v2"
"table": "session"
},
{
"columns": [
@@ -1920,9 +2261,9 @@
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_v2_parent_idx",
"name": "session_parent_idx",
"entityType": "indexes",
"table": "session_v2"
"table": "session"
},
{
"columns": [
@@ -1932,11 +2273,11 @@
}
],
"isUnique": false,
"where": "\"session_v2\".\"time_suspended\" is not null",
"where": "\"session\".\"time_suspended\" is not null",
"origin": "manual",
"name": "session_v2_time_suspended_idx",
"name": "session_time_suspended_idx",
"entityType": "indexes",
"table": "session_v2"
"table": "session"
}
],
"renames": []
@@ -1,70 +0,0 @@
import path from "path"
import { Effect, Logger } from "effect"
import { AppNodeBuilder } from "../src/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Database } from "../src/database/database"
import { Bus } from "../src/bus"
import { SdkPlugins } from "../src/plugin/sdk"
import { Location } from "../src/location"
import { LocationServiceMap } from "../src/location-service-map"
import { AbsolutePath } from "../src/schema"
const args = process.argv.slice(2)
const iterationsIndex = args.indexOf("--iterations")
const iterations = iterationsIndex === -1 ? 10 : Number(args[iterationsIndex + 1])
const directory = args.find((arg, index) => !arg.startsWith("--") && index !== iterationsIndex + 1) ?? process.cwd()
if (!Number.isInteger(iterations) || iterations < 1) {
console.error("--iterations must be a positive integer")
process.exit(1)
}
const ref = Location.Ref.make({ directory: AbsolutePath.make(path.resolve(directory)) })
const layer = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]),
)
const measure = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const start = performance.now()
yield* effect
return performance.now() - start
})
const stats = (samples: ReadonlyArray<number>) => {
const sorted = samples.toSorted((a, b) => a - b)
const percentile = (value: number) => sorted[Math.min(Math.ceil(sorted.length * value) - 1, sorted.length - 1)]
return {
mean: samples.reduce((total, sample) => total + sample, 0) / samples.length,
min: sorted[0] ?? 0,
p50: percentile(0.5),
p95: percentile(0.95),
max: sorted.at(-1) ?? 0,
}
}
const print = (name: string, samples: ReadonlyArray<number>) => {
const result = stats(samples)
console.log(
`${name.padEnd(12)} mean ${result.mean.toFixed(2)} ms min ${result.min.toFixed(2)} ms p50 ${result.p50.toFixed(2)} ms p95 ${result.p95.toFixed(2)} ms max ${result.max.toFixed(2)} ms`,
)
}
const program = Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const load = locations.contextEffect(ref).pipe(Effect.scoped)
const first = yield* measure(load)
const cached = yield* Effect.forEach(Array.from({ length: iterations }), () => measure(load))
const cold = yield* Effect.forEach(Array.from({ length: iterations }), () =>
locations.invalidate(ref).pipe(Effect.andThen(measure(load))),
)
console.log(`Location: ${ref.directory}`)
console.log(`Iterations: ${iterations}`)
print("first", [first])
print("cached", cached)
print("cold", cold)
}).pipe(Effect.scoped, Effect.provide(layer), Effect.provide(Logger.layer([])))
await Effect.runPromise(program)
+4 -8
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs/promises"
import os from "os"
import path from "path"
@@ -99,14 +100,9 @@ async function drizzle(temporary: string, output: string, name?: string) {
export default { ...config, out: ${JSON.stringify(output)} }
`,
)
const child = Bun.spawn(["bun", "drizzle-kit", "generate", "--config", config, ...(name ? ["--name", name] : [])], {
cwd: path.join(root, "packages/core"),
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
})
const exit = await child.exited
if (exit !== 0) throw new Error(`Drizzle generation failed with exit code ${exit}.`)
await $`bun drizzle-kit generate --config ${config} ${name ? ["--name", name] : []}`.cwd(
path.join(root, "packages/core"),
)
}
async function generatedMigrations(directory: string) {
+6
View File
@@ -0,0 +1,6 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
export const DataMigrationTable = sqliteTable("data_migration", {
name: text().primaryKey(),
time_completed: integer().notNull(),
})
+19 -1
View File
@@ -40,6 +40,24 @@ export const migrations = (
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260702134641_add_session_context_entry"),
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
import("./migration/20260703181610_event_created_column"),
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
import("./migration/20260703200000_reset_v2_session_events"),
import("./migration/20260705180000_rename_instructions"),
import("./migration/20260706223930_add-session-fork"),
import("./migration/20260707010146_durable_session_inbox"),
import("./migration/20260707120000_migrate_prelaunch_v2_state"),
import("./migration/20260709013000_generic_session_input"),
import("./migration/20260709025533_drop-todo"),
import("./migration/20260709163752_time_suspended"),
import("./migration/20260709190621_session_pending_table"),
import("./migration/20260710025429_instruction_sync"),
import("./migration/20260716020354_kv"),
import("./migration/20260722011141_delete_tool_progress_events"),
import("./migration/20260722170000_canonical_tool_results"),
import("./migration/20260729022634_session_fork_boundary"),
import("./migration/20260730195856_optional_session_title"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
+2 -43
View File
@@ -12,7 +12,6 @@ const lock = Semaphore.makeUnsafe(1)
export type Migration = {
id: string
foreignKeys?: boolean
up: (tx: Transaction) => Effect.Effect<void, unknown>
}
@@ -22,11 +21,8 @@ export function apply(db: Database) {
const tables = yield* db.all<{ name: string }>(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
)
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
return yield* applyOnly(db, migrations)
if (tables.some((table) => table.name === "session")) return yield* applyOnly(db, migrations)
if (tables.length > 0) return yield* Effect.die(new Error("Database is not empty and has no session table"))
const started = Date.now()
yield* Effect.logInfo("database schema bootstrap started", { migrations: migrations.length })
yield* db.transaction((tx) =>
Effect.gen(function* () {
yield* schema.up(tx)
@@ -40,10 +36,6 @@ export function apply(db: Database) {
)
}),
)
yield* Effect.logInfo("database schema bootstrap completed", {
migrations: migrations.length,
durationMs: Date.now() - started,
})
}),
)
}
@@ -76,9 +68,7 @@ export function applyOnly(db: Database, input: Migration[]) {
for (const migration of input) {
if (completed.has(migration.id)) continue
const started = Date.now()
yield* Effect.logInfo("database migration started", { migration: migration.id })
const apply = db.transaction((tx) =>
yield* db.transaction((tx) =>
Effect.gen(function* () {
yield* migration.up(tx)
yield* tx.run(
@@ -86,37 +76,6 @@ export function applyOnly(db: Database, input: Migration[]) {
)
}),
)
if (migration.foreignKeys !== false) {
yield* apply.pipe(
Effect.tapError((error) =>
Effect.logError("database migration failed", {
migration: migration.id,
durationMs: Date.now() - started,
error,
}),
),
)
yield* Effect.logInfo("database migration completed", {
migration: migration.id,
durationMs: Date.now() - started,
})
continue
}
yield* db.run(sql`PRAGMA foreign_keys = OFF`)
yield* apply.pipe(
Effect.ensuring(db.run(sql`PRAGMA foreign_keys = ON`).pipe(Effect.orDie)),
Effect.tapError((error) =>
Effect.logError("database migration failed", {
migration: migration.id,
durationMs: Date.now() - started,
error,
}),
),
)
yield* Effect.logInfo("database migration completed", {
migration: migration.id,
durationMs: Date.now() - started,
})
}
})
}
@@ -10,6 +10,8 @@ export default {
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
yield* tx.run(`DELETE FROM \`workspace\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,21 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260702134641_add_session_context_entry",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_context_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`session_context_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_session_context_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703090000_reset_v2_event_rename_sweep",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
// `created` column is added by the generated 20260703181610_event_created_column
// migration, which runs after this wipe (NOT NULL without default is safe on the
// emptied table).
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703181610_event_created_column",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`event\` ADD \`created\` integer NOT NULL;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703190000_reset_v2_shell_event_payloads",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703200000_reset_v2_session_events",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260705180000_rename_instructions",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_entry\` RENAME TO \`instruction_entry\``)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` RENAME TO \`instruction_checkpoint\``)
yield* tx.run(`
UPDATE \`event\`
SET \`type\` = 'session.instructions.updated.1'
WHERE \`type\` = 'session.context.updated.1'
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,39 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260706223930_add-session-fork",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_session_id\` text;`)
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_message_id\` text;`)
yield* tx.run(`
UPDATE \`session\`
SET
\`parent_id\` = NULL,
\`fork_session_id\` = (
SELECT json_extract(\`event\`.\`data\`, '$.parentID')
FROM \`event\`
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
AND \`event\`.\`type\` = 'session.forked'
ORDER BY \`event\`.\`seq\`
LIMIT 1
),
\`fork_message_id\` = (
SELECT json_extract(\`event\`.\`data\`, '$.from')
FROM \`event\`
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
AND \`event\`.\`type\` = 'session.forked'
ORDER BY \`event\`.\`seq\`
LIMIT 1
)
WHERE EXISTS (
SELECT 1
FROM \`event\`
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
AND \`event\`.\`type\` = 'session.forked'
);
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,43 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260707010146_durable_session_inbox",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_session_input\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`prompt\` text,
\`delivery\` text,
\`admitted_seq\` integer NOT NULL,
\`promoted_seq\` integer,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(
`INSERT INTO \`__new_session_input\`(\`id\`, \`session_id\`, \`type\`, \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\`) SELECT \`id\`, \`session_id\`, 'prompt', \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\` FROM \`session_input\`;`,
)
yield* tx.run(`DROP TABLE \`session_input\`;`)
yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`)
yield* tx.run(`PRAGMA foreign_keys=ON;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_input_session_pending_delivery_seq_idx\`;`)
yield* tx.run(
`CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,227 @@
import { sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import type { DatabaseMigration } from "../migration"
const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString)
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
export default {
id: "20260707120000_migrate_prelaunch_v2_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(
sql`DELETE FROM session_message WHERE type = 'compaction' AND json_extract(data, '$.status') = 'queued'`,
)
const messages = yield* tx.all<{ id: string; type: string; data: string }>(
sql`SELECT id, type, data FROM session_message WHERE type IN ('skill', 'shell', 'assistant', 'compaction', 'synthetic')`,
)
for (const row of messages) {
const data = object(decodeJson(row.data))
yield* tx.run(
sql`UPDATE session_message SET data = ${JSON.stringify(messageData(row.type, data))} WHERE id = ${row.id}`,
)
}
yield* tx.run(sql`DELETE FROM event WHERE type = 'session.compaction.delta.1'`)
const events = yield* tx.all<{ id: string; aggregateID: string; seq: number; type: string; data: string }>(sql`
SELECT id, aggregate_id as aggregateID, seq, type, data
FROM event
WHERE type IN (
'session.skill.activated.1',
'session.skill.activated.2',
'session.compaction.started.1',
'session.compaction.started.2',
'session.compaction.ended.1',
'session.compaction.failed.1',
'session.compaction.failed.2',
'session.revert.staged.1',
'session.revert.staged.2'
)
ORDER BY aggregate_id, seq
`)
const compactionReasons = new Map<string, "auto" | "manual">()
for (const row of events) {
const data = object(decodeJson(row.data))
if (row.type.startsWith("session.compaction.ended.")) {
compactionReasons.delete(row.aggregateID)
continue
}
const event = eventData(row.type, data, compactionReasons.get(row.aggregateID))
if (row.type.startsWith("session.compaction.started."))
compactionReasons.set(row.aggregateID, event.data.reason === "auto" ? "auto" : "manual")
if (row.type.startsWith("session.compaction.failed.")) compactionReasons.delete(row.aggregateID)
yield* tx.run(
sql`UPDATE event SET type = ${event.type}, data = ${JSON.stringify(event.data)} WHERE id = ${row.id}`,
)
}
})
},
} satisfies DatabaseMigration.Migration
function messageData(type: string, data: Record<string, unknown>) {
if (type === "skill")
return defined({
metadata: data.metadata,
time: data.time,
skill: data.skill ?? data.id ?? data.name,
name: data.name,
text: data.text,
})
if (type === "shell") {
const shell = object(data.shell)
return defined({
metadata: data.metadata,
time: data.time,
shellID: data.shellID ?? shell.id,
command: data.command ?? shell.command,
status: data.status ?? shell.status,
exit: data.exit ?? shell.exit,
output: data.output,
})
}
if (type === "assistant")
return defined({
metadata: data.metadata,
time: data.time,
agent: data.agent,
model: data.model,
content: Array.isArray(data.content) ? data.content.map(assistantContent) : data.content,
snapshot: data.snapshot,
finish: data.finish,
cost: data.cost,
tokens: data.tokens,
error: data.error,
retry: data.retry,
})
if (type === "compaction") {
if (data.status === "failed")
return defined({
metadata: data.metadata,
time: data.time,
status: data.status,
reason: data.reason,
error: data.error ?? genericCompactionError,
})
return defined({
metadata: data.metadata,
time: data.time,
status: data.status,
reason: data.reason,
summary: data.summary,
recent: data.recent,
})
}
if (type === "synthetic")
return defined({ metadata: data.metadata, time: data.time, text: data.text, description: data.description })
const { sessionID: _, ...current } = data
return current
}
function assistantContent(value: unknown) {
const content = object(value)
if (content.type === "text") return defined({ type: content.type, text: content.text })
if (content.type === "reasoning")
return defined({ type: content.type, text: content.text, state: content.state, time: content.time })
if (content.type !== "tool") return content
return defined({
type: content.type,
id: content.id,
name: content.name,
executed: content.executed,
providerState: content.providerState,
providerResultState: content.providerResultState,
state: toolState(content.state),
time: content.time,
})
}
function toolState(value: unknown) {
const state = object(value)
if (state.status === "pending" || state.status === "streaming")
return defined({ status: "streaming", input: state.input })
if (state.status === "running")
return defined({ status: state.status, input: state.input, structured: state.structured, content: state.content })
if (state.status === "completed")
return defined({
status: state.status,
input: state.input,
structured: state.structured,
content: state.content,
result: state.result,
})
if (state.status === "error")
return defined({
status: state.status,
input: state.input,
structured: state.structured,
content: state.content,
error: state.error,
result: state.result,
})
return state
}
function eventData(type: string, data: Record<string, unknown>, compactionReason?: "auto" | "manual") {
if (type.startsWith("session.skill.activated."))
return {
type: "session.skill.activated.1",
data: defined({ sessionID: data.sessionID, id: data.id ?? data.name, name: data.name, text: data.text }),
}
if (type.startsWith("session.compaction.started."))
return {
type: "session.compaction.started.1",
data: defined({
sessionID: data.sessionID,
reason: data.reason,
recent: data.recent ?? "",
inputID: data.inputID,
}),
}
if (type.startsWith("session.compaction.failed."))
return {
type: "session.compaction.failed.1",
data: defined({
sessionID: data.sessionID,
reason: data.reason ?? compactionReason ?? "manual",
error: data.error ?? genericCompactionError,
inputID: data.inputID,
}),
}
const revert = object(data.revert)
return {
type: "session.revert.staged.1",
data: defined({
sessionID: data.sessionID,
revert: defined({
messageID: revert.messageID,
partID: revert.partID,
snapshot: revert.snapshot,
files: Array.isArray(revert.files)
? revert.files.map((value) => {
const file = object(value)
return defined({
file: file.file ?? file.path,
patch: file.patch,
additions: file.additions,
deletions: file.deletions,
status: file.status,
})
})
: undefined,
}),
}),
}
}
const genericCompactionError = {
type: "compaction.failed",
message: "Compaction failed before recording an error",
}
function object(value: unknown): Record<string, unknown> {
return isObject(value) ? value : {}
}
function defined(value: Record<string, unknown>) {
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined))
}
@@ -0,0 +1,77 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260709013000_generic_session_input",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
DELETE FROM \`event\`
WHERE \`type\` IN ('session.prompt.admitted.1', 'session.prompt.promoted.1')
AND json_extract(\`data\`, '$.inputID') IN (
SELECT \`id\` FROM \`session_input\` WHERE \`type\` = 'prompt' AND \`prompt\` IS NULL
);
`)
yield* tx.run(`
CREATE TABLE \`__new_session_input\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`data\` text NOT NULL,
\`delivery\` text,
\`admitted_seq\` integer NOT NULL,
\`promoted_seq\` integer,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
INSERT INTO \`__new_session_input\`(
\`id\`, \`session_id\`, \`type\`, \`data\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\`
)
SELECT
\`id\`, \`session_id\`, CASE WHEN \`type\` = 'prompt' THEN 'user' ELSE \`type\` END,
CASE WHEN \`type\` = 'prompt' THEN \`prompt\` ELSE '{}' END,
\`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\`
FROM \`session_input\`
WHERE \`type\` != 'prompt' OR \`prompt\` IS NOT NULL;
`)
yield* tx.run(`DROP TABLE \`session_input\`;`)
yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`)
yield* tx.run(`PRAGMA foreign_keys=ON;`)
yield* tx.run(
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE \`type\` = 'compaction' and \`promoted_seq\` is null;`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
)
yield* tx.run(`
UPDATE \`event\`
SET
\`type\` = 'session.input.admitted.1',
\`data\` = json_object(
'sessionID', json_extract(\`data\`, '$.sessionID'),
'inputID', json_extract(\`data\`, '$.inputID'),
'input', json_object(
'type', 'user',
'data', json_extract(\`data\`, '$.prompt'),
'delivery', json_extract(\`data\`, '$.delivery')
)
)
WHERE \`type\` = 'session.prompt.admitted.1';
`)
yield* tx.run(`
UPDATE \`event\`
SET \`type\` = 'session.input.promoted.1'
WHERE \`type\` = 'session.prompt.promoted.1';
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,12 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260709025533_drop-todo",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP INDEX IF EXISTS \`todo_session_idx\`;`)
yield* tx.run(`DROP TABLE \`todo\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260709163752_time_suspended",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`time_suspended\` integer;`)
yield* tx.run(
`CREATE INDEX \`session_time_suspended_idx\` ON \`session\` (\`time_suspended\`) WHERE "session"."time_suspended" is not null;`,
)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,35 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260709190621_session_pending_table",
up(tx) {
return Effect.gen(function* () {
// Beta reset: session_input becomes the pending-only session_pending
// table. Dropping the old table discards consumed ledger rows and any
// in-flight pending work along with every historical index variant.
yield* tx.run(`DROP TABLE \`session_input\`;`)
yield* tx.run(`
CREATE TABLE \`session_pending\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`data\` text NOT NULL,
\`delivery\` text,
\`admitted_seq\` integer NOT NULL,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_pending_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(
`CREATE INDEX \`session_pending_session_delivery_seq_idx\` ON \`session_pending\` (\`session_id\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_pending_session_compaction_idx\` ON \`session_pending\` (\`session_id\`) WHERE "session_pending"."type" = 'compaction';`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_pending_session_admitted_seq_idx\` ON \`session_pending\` (\`session_id\`,\`admitted_seq\`);`,
)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,86 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260710025429_instruction_sync",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_seq\` integer;`)
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_instruction_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text,
\`removed\` integer DEFAULT false NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_instruction_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
INSERT INTO \`__new_instruction_entry\`(
\`session_id\`, \`key\`, \`value\`, \`removed\`, \`time_created\`, \`time_updated\`
)
SELECT \`session_id\`, \`key\`, \`value\`, false, \`time_created\`, \`time_updated\`
FROM \`instruction_entry\`;
`)
yield* tx.run(`DROP TABLE \`instruction_entry\`;`)
yield* tx.run(`ALTER TABLE \`__new_instruction_entry\` RENAME TO \`instruction_entry\`;`)
yield* tx.run(`PRAGMA foreign_keys=ON;`)
yield* tx.run(`
CREATE TABLE \`instruction_blob\` (
\`hash\` text PRIMARY KEY,
\`value\` text
);
`)
yield* tx.run(`
CREATE TABLE \`instruction_state\` (
\`session_id\` text PRIMARY KEY,
\`epoch_start\` integer NOT NULL,
\`through_seq\` integer NOT NULL,
\`initial_values\` text NOT NULL,
\`current_values\` text NOT NULL,
CONSTRAINT \`fk_instruction_state_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
// Persisted System rows were exclusively pre-beta instruction prose,
// including fork copies whose message IDs no longer match the source event.
yield* tx.run(`DELETE FROM \`session_message\` WHERE \`type\` = 'system';`)
yield* tx.run(`
UPDATE \`session\`
SET \`fork_seq\` = COALESCE(
(
SELECT MIN(\`seq\`) - 1
FROM \`event\`
WHERE \`aggregate_id\` = \`session\`.\`id\` AND \`seq\` > 0
),
(
SELECT \`seq\`
FROM \`event_sequence\`
WHERE \`aggregate_id\` = \`session\`.\`id\`
),
0
)
WHERE \`fork_session_id\` IS NOT NULL;
`)
yield* tx.run(`
UPDATE \`event\`
SET
\`type\` = 'session.forked.2',
\`data\` = json_set(
\`data\`,
'$.parentSeq',
COALESCE(
(SELECT \`fork_seq\` FROM \`session\` WHERE \`id\` = \`event\`.\`aggregate_id\`),
0
)
)
WHERE \`type\` = 'session.forked.1';
`)
yield* tx.run(`DELETE FROM \`event\` WHERE \`type\` = 'session.instructions.updated.1';`)
yield* tx.run(`DROP TABLE \`instruction_checkpoint\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,18 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260716020354_kv",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`kv\` (
\`key\` text PRIMARY KEY,
\`value\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260722011141_delete_tool_progress_events",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`event\` WHERE \`type\` = 'session.tool.progress.1';`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,123 @@
import { sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import type { DatabaseMigration } from "../migration"
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
const isJsonObject = Schema.is(Schema.Record(Schema.String, Schema.Json))
const object = (value: unknown): Record<string, unknown> => (isObject(value) ? value : {})
const stringify = (value: unknown) => {
try {
return JSON.stringify(value, null, 2) ?? String(value)
} catch {
return String(value)
}
}
const contentOf = (state: Record<string, unknown>) => (Array.isArray(state.content) ? state.content : [])
const resultOf = (state: Record<string, unknown>) =>
isObject(state.result) && "value" in state.result ? state.result.value : state.result
const metadataOf = (state: Record<string, unknown>) => {
if (isJsonObject(state.structured) && Object.keys(state.structured).length > 0)
return { metadata: state.structured }
return isJsonObject(state.metadata) ? { metadata: state.metadata } : {}
}
const completedContent = (state: Record<string, unknown>) => {
const preserved = contentOf(state)
if (preserved.length > 0) return preserved
return [{ type: "text", text: stringify(Object.keys(object(state.structured)).length ? state.structured : resultOf(state)) }]
}
/**
* One-time rewrite of projected tool rows into the canonical result shape:
* terminal states store model content plus optional metadata; the generic
* `structured` and `result` fields disappear. Provider-hosted result payloads
* move into provider-owned result state so hosted continuation survives.
* Pre-release durable event versions are intentionally left untouched.
*/
export default {
id: "20260722170000_canonical_tool_results",
up(tx) {
return Effect.gen(function* () {
// Keyset-paginated batches keep memory bounded: production databases hold
// gigabytes of assistant rows, and materializing them all at once was
// measured at a ~5GB RSS spike.
let cursor = ""
while (true) {
const messages = yield* tx.all<{ id: string; data: string }>(
sql`SELECT id, data FROM session_message WHERE type = 'assistant' AND id > ${cursor} ORDER BY id LIMIT 1000`,
)
if (messages.length === 0) break
cursor = messages[messages.length - 1].id
yield* rewrite(tx, messages)
}
})
},
} satisfies DatabaseMigration.Migration
function rewrite(tx: Parameters<DatabaseMigration.Migration["up"]>[0], messages: { id: string; data: string }[]) {
return Effect.gen(function* () {
for (const row of messages) {
// A row that never decoded is skipped rather than failing the whole
// migration on every startup; it was equally unreadable before.
const decoded = decodeJson(row.data)
if (decoded._tag === "None") {
yield* Effect.logWarning("skipping undecodable session_message row").pipe(Effect.annotateLogs({ id: row.id }))
continue
}
const data = object(decoded.value)
if (!Array.isArray(data.content)) continue
let changed = false
const content = data.content.map((part) => {
const tool = object(part)
if (tool.type !== "tool" || !isObject(tool.state)) return part
const state = tool.state
if (state.status !== "completed" && state.status !== "error" && state.status !== "running") return part
if (!("structured" in state) && !("result" in state)) return part
changed = true
if (state.status === "running")
return {
...tool,
state: {
status: "running",
input: object(state.input),
metadata: object(state.structured),
},
}
// Hosted payloads are irreducible provider replay state; keep them under
// the provider-owned result state instead of a generic result field.
const hosted =
tool.executed === true && isObject(state.result) && "value" in state.result
? { providerResultState: { ...object(tool.providerResultState), result: state.result.value } }
: {}
const preserved = contentOf(state)
if (state.status === "completed")
return {
...tool,
...hosted,
state: {
status: "completed",
input: object(state.input),
content: completedContent(state),
...metadataOf(state),
},
}
return {
...tool,
...hosted,
state: {
status: "error",
input: object(state.input),
error: state.error,
...(preserved.length > 0 ? { content: preserved } : {}),
...metadataOf(state),
},
}
})
if (!changed) continue
yield* tx.run(sql`UPDATE session_message SET data = ${JSON.stringify({ ...data, content })} WHERE id = ${row.id}`)
}
})
}
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260729022634_session_fork_boundary",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_boundary\` text;`)
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_message_id\`;`)
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_seq\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260730195856_optional_session_title",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` RENAME COLUMN \`title\` TO \`title_old\``)
yield* tx.run(`ALTER TABLE \`session\` ADD COLUMN \`title\` text`)
yield* tx.run(`UPDATE \`session\` SET \`title\` = \`title_old\``)
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`title_old\``)
})
},
} satisfies DatabaseMigration.Migration
@@ -1,138 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260804233008_loose_psylocke",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE IF NOT EXISTS \`kv\` (
\`key\` text PRIMARY KEY,
\`value\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
yield* tx.run(`
CREATE TABLE IF NOT EXISTS \`instruction_blob\` (
\`hash\` text PRIMARY KEY,
\`value\` text
);
`)
yield* tx.run(`
CREATE TABLE IF NOT EXISTS \`instruction_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text,
\`removed\` integer DEFAULT false NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_instruction_entry_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE IF NOT EXISTS \`instruction_state\` (
\`session_id\` text PRIMARY KEY,
\`epoch_start\` integer NOT NULL,
\`through_seq\` integer NOT NULL,
\`initial_values\` text NOT NULL,
\`current_values\` text NOT NULL,
CONSTRAINT \`fk_instruction_state_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE IF NOT EXISTS \`session_pending\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`data\` text NOT NULL,
\`delivery\` text,
\`admitted_seq\` integer NOT NULL,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_pending_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE IF NOT EXISTS \`session_v2\` (
\`id\` text PRIMARY KEY,
\`project_id\` text NOT NULL,
\`workspace_id\` text,
\`parent_id\` text,
\`fork_session_id\` text,
\`fork_boundary\` text,
\`slug\` text NOT NULL,
\`directory\` text NOT NULL,
\`path\` text,
\`title\` text,
\`version\` text NOT NULL,
\`share_url\` text,
\`summary_additions\` integer,
\`summary_deletions\` integer,
\`summary_files\` integer,
\`summary_diffs\` text,
\`metadata\` text,
\`cost\` real DEFAULT 0 NOT NULL,
\`tokens_input\` integer DEFAULT 0 NOT NULL,
\`tokens_output\` integer DEFAULT 0 NOT NULL,
\`tokens_reasoning\` integer DEFAULT 0 NOT NULL,
\`tokens_cache_read\` integer DEFAULT 0 NOT NULL,
\`tokens_cache_write\` integer DEFAULT 0 NOT NULL,
\`revert\` text,
\`permission\` text,
\`agent\` text,
\`model\` text,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`time_compacting\` integer,
\`time_archived\` integer,
\`time_suspended\` integer,
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`ALTER TABLE \`event\` ADD \`created\` integer DEFAULT 0 NOT NULL;`)
yield* tx.run(`
CREATE TABLE IF NOT EXISTS \`__new_session_message\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`seq\` integer NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_session_message_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`DROP TABLE \`session_message\`;`)
yield* tx.run(`ALTER TABLE \`__new_session_message\` RENAME TO \`session_message\`;`)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
)
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
yield* tx.run(
`CREATE INDEX \`session_pending_session_delivery_seq_idx\` ON \`session_pending\` (\`session_id\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_pending_session_compaction_idx\` ON \`session_pending\` (\`session_id\`) WHERE "session_pending"."type" = 'compaction';`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_pending_session_admitted_seq_idx\` ON \`session_pending\` (\`session_id\`,\`admitted_seq\`);`,
)
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
yield* tx.run(
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
)
yield* tx.run(`DROP TABLE \`data_migration\`;`)
yield* tx.run(`DROP TABLE \`session_context_epoch\`;`)
yield* tx.run(`DROP TABLE \`session_input\`;`)
})
},
} satisfies DatabaseMigration.Migration
+54 -11
View File
@@ -17,6 +17,12 @@ export default {
CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`data_migration\` (
\`name\` text PRIMARY KEY,
\`time_completed\` integer NOT NULL
);
`)
yield* tx.run(`
CREATE TABLE \`account_state\` (
\`id\` integer PRIMARY KEY,
@@ -75,7 +81,7 @@ export default {
\`id\` text PRIMARY KEY,
\`aggregate_id\` text NOT NULL,
\`seq\` integer NOT NULL,
\`created\` integer DEFAULT 0 NOT NULL,
\`created\` integer NOT NULL,
\`type\` text NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
@@ -142,7 +148,7 @@ export default {
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_instruction_entry_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
CONSTRAINT \`fk_instruction_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
@@ -152,7 +158,28 @@ export default {
\`through_seq\` integer NOT NULL,
\`initial_values\` text NOT NULL,
\`current_values\` text NOT NULL,
CONSTRAINT \`fk_instruction_state_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
CONSTRAINT \`fk_instruction_state_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`message\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`part\` (
\`id\` text PRIMARY KEY,
\`message_id\` text NOT NULL,
\`session_id\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_part_message_id_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`message\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
@@ -164,7 +191,7 @@ export default {
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_session_message_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
CONSTRAINT \`fk_session_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
@@ -176,11 +203,11 @@ export default {
\`delivery\` text,
\`admitted_seq\` integer NOT NULL,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_pending_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
CONSTRAINT \`fk_session_pending_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_v2\` (
CREATE TABLE \`session\` (
\`id\` text PRIMARY KEY,
\`project_id\` text NOT NULL,
\`workspace_id\` text,
@@ -213,7 +240,18 @@ export default {
\`time_compacting\` integer,
\`time_archived\` integer,
\`time_suspended\` integer,
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
CONSTRAINT \`fk_session_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_share\` (
\`session_id\` text PRIMARY KEY,
\`id\` text NOT NULL,
\`secret\` text NOT NULL,
\`url\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`fk_session_share_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
@@ -221,6 +259,11 @@ export default {
yield* tx.run(
`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`,
)
yield* tx.run(
`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`,
)
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
)
@@ -240,11 +283,11 @@ export default {
yield* tx.run(
`CREATE UNIQUE INDEX \`session_pending_session_admitted_seq_idx\` ON \`session_pending\` (\`session_id\`,\`admitted_seq\`);`,
)
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`)
yield* tx.run(
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
`CREATE INDEX \`session_time_suspended_idx\` ON \`session\` (\`time_suspended\`) WHERE "session"."time_suspended" is not null;`,
)
})
},
-690
View File
@@ -1,690 +0,0 @@
export * as V1Migration from "./v1-migration"
import { Effect, Option, Schema, Semaphore } from "effect"
import { Database } from "./database"
import { SessionMessageTable, SessionTable } from "../session/sql"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
import { KVTable } from "../kv/sql"
import { EventSequenceTable, EventTable } from "../event/sql"
import { eq, sql } from "drizzle-orm"
export type SourceMessage = {
readonly id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type SourcePart = {
readonly id: string
readonly message_id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type TransformInput = {
readonly session: typeof SessionTable.$inferSelect
readonly messages: ReadonlyArray<SourceMessage>
readonly parts: ReadonlyArray<SourcePart>
}
export type Warning = {
readonly reason: string
readonly sessionID: string
readonly messageID?: string
readonly partID?: string
readonly observedType?: string
}
export type TransformResult = {
readonly messages: ReadonlyArray<{
readonly id: string
readonly session_id: string
readonly type: SessionMessage.Type
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: Record<string, unknown>
}>
readonly session: Pick<
typeof SessionTable.$inferInsert,
| "agent"
| "model"
| "cost"
| "tokens_input"
| "tokens_output"
| "tokens_reasoning"
| "tokens_cache_read"
| "tokens_cache_write"
| "revert"
| "time_compacting"
>
readonly watermark: number
readonly warnings: ReadonlyArray<Warning>
}
export type Status = {
readonly status: "required" | "running" | "completed"
readonly completed: number
readonly total: number
}
export type Result = {
readonly status: "completed"
}
const lock = Semaphore.makeUnsafe(1)
const cursorKey = "migration.v1-v2.session.cursor"
const completedKey = "migration.v1-v2.completed"
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
let running = false
export function transformSession(input: TransformInput): TransformResult {
const warnings: Warning[] = []
const messages = input.messages
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(decodeMessage({ ...value, id: row.id, sessionID: row.session_id }))
: undefined
if (decoded) return { row, value: decoded }
warnings.push({ reason: "invalid-message", sessionID: input.session.id, messageID: row.id })
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.time_created - b.row.time_created || a.row.id.localeCompare(b.row.id))
const messageIDs = new Set(input.messages.map((row) => row.id))
const parts = input.parts
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const observedType = value && typeof value === "object" && "type" in value ? String(value.type) : undefined
if (!messageIDs.has(row.message_id)) {
warnings.push({
reason: "orphan-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
}
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(
decodePart({ ...value, id: row.id, messageID: row.message_id, sessionID: row.session_id }),
)
: undefined
if (decoded) return { row, value: decoded }
warnings.push({
reason: "invalid-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.id.localeCompare(b.row.id))
const byMessage = Map.groupBy(parts, (item) => item.row.message_id)
const paired = new Set<string>()
const used = new Set(messages.map((item) => item.row.id))
const projected = messages
.flatMap((item) => {
if (paired.has(item.row.id)) return []
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
if (item.value.role === "user") {
const compaction = owned.find((part) => part.type === "compaction")
if (compaction?.type === "compaction") {
const pairedSummary = messages.find(
(candidate) =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary,
)
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
paired.add(pairedSummary.row.id)
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
const summary = pairedSummary
const summaryText = (byMessage.get(summary.row.id) ?? [])
.map((part) => part.value)
.filter((part) => part.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
: -1
const compactionIndex = messages.findIndex((candidate) => candidate.row.id === item.row.id)
const tail = tailIndex < 0 ? [] : messages.slice(tailIndex, compactionIndex)
return [
row(
{ ...item.row, time_updated: Math.max(item.row.time_updated, summary.row.time_updated) },
{
id: item.row.id,
type: "compaction",
status: "completed",
reason: compaction.auto ? "auto" : "manual",
summary: summaryText,
recent: serializeRecent(tail, byMessage),
time: { created: item.row.time_created },
},
),
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
const files = owned.filter((part) => part.type === "file")
const agents = owned.filter((part) => part.type === "agent")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
const unavailable = files.flatMap((part) =>
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
)
const text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
if (part.type === "file" && !part.url.startsWith("data:")) return [unavailableFile(part)]
return []
})
.join("\n\n")
const agentAttachments = agents.map((part) =>
part.type === "agent"
? {
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}
: { name: "" },
)
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
synthetic.length > 0 &&
attachments.length === 0 &&
agentAttachments.length === 0
)
return [
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
const user = row(item.row, {
id: item.row.id,
type: "user",
text,
...(attachments.length ? { files: attachments } : {}),
...(agentAttachments.length ? { agents: agentAttachments } : {}),
time: { created: item.row.time_created },
})
if (synthetic.length === 0) return [user]
return [
user,
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
}
if (item.value.role !== "assistant") return []
const assistant = item.value
const parent = messages.find((candidate) => candidate.row.id === assistant.parentID)
const parentParts = parent ? (byMessage.get(parent.row.id)?.map((part) => part.value) ?? []) : []
if (
parentParts.some((part) => part.type === "subtask") &&
owned.some((part) => part.type === "tool" && part.tool === "task")
)
return []
const content = owned.flatMap((part): Array<Record<string, unknown>> => {
if (part.type === "text")
return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
...(part.metadata ? { state: part.metadata } : {}),
time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) },
},
]
if (part.type !== "tool") return []
return [migrateTool(part, item.row.time_created)]
})
const start =
owned.flatMap((part) => (part.type === "step-start" && part.snapshot ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "snapshot" ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "patch" ? [part.hash] : []))[0]
const end = owned.flatMap((part) => (part.type === "step-finish" && part.snapshot ? [part.snapshot] : [])).at(-1)
const snapshotFiles = Array.from(new Set(owned.flatMap((part) => (part.type === "patch" ? part.files : []))))
const finish = normalizeFinish(assistant.finish)
return [
row(item.row, {
id: item.row.id,
type: "assistant",
agent: assistant.agent,
model: {
providerID: assistant.providerID,
id: assistant.modelID,
variant: assistant.variant ?? "default",
},
content,
...(start || end || snapshotFiles.length
? {
snapshot: {
...(start ? { start } : {}),
...(end ? { end } : {}),
...(snapshotFiles.length ? { files: snapshotFiles } : {}),
},
}
: {}),
...(finish ? { finish } : {}),
cost: assistant.cost,
tokens: {
input: assistant.tokens.input,
output: assistant.tokens.output,
reasoning: assistant.tokens.reasoning,
cache: assistant.tokens.cache,
},
...(assistant.error ? { error: migrateError(assistant.error) } : {}),
time: {
created: item.row.time_created,
...(assistant.time.completed === undefined ? {} : { completed: item.row.time_updated }),
},
}),
]
})
.map((item, seq) => ({ ...item, seq }))
const assistants = messages
.filter((item) => item.value.role === "assistant")
.map((item) => item.value)
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
const latestUser = messages.findLast((item) => {
if (item.value.role !== "user") return false
const owned = byMessage.get(item.row.id) ?? []
if (owned.some((part) => part.value.type === "compaction")) return false
return !owned.some((part) => part.value.type === "subtask") || !owned.every((part) => part.value.type === "subtask")
})
return {
messages: projected,
session: {
agent: input.session.agent ?? (latestUser?.value.role === "user" ? latestUser.value.agent : null),
model:
input.session.model ??
(latestUser?.value.role === "user"
? {
id: latestUser.value.model.modelID,
providerID: latestUser.value.model.providerID,
variant: latestUser.value.model.variant ?? "default",
}
: null),
cost: assistants.reduce((total, item) => total + item.cost, 0),
tokens_input: assistants.reduce((total, item) => total + item.tokens.input, 0),
tokens_output: assistants.reduce((total, item) => total + item.tokens.output, 0),
tokens_reasoning: assistants.reduce((total, item) => total + item.tokens.reasoning, 0),
tokens_cache_read: assistants.reduce((total, item) => total + item.tokens.cache.read, 0),
tokens_cache_write: assistants.reduce((total, item) => total + item.tokens.cache.write, 0),
revert: null,
time_compacting: null,
},
watermark: projected.length - 1,
warnings,
}
}
export function status(): Effect.Effect<Status, never, Database.Service> {
return Effect.gen(function* () {
const { db } = yield* Database.Service
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const, completed: 0, total: 0 }
const completed =
(yield* db
.select({ value: KVTable.value })
.from(KVTable)
.where(eq(KVTable.key, completedKey))
.get()
.pipe(Effect.orDie)) !== undefined
const cursor = yield* db
.select({ value: KVTable.value })
.from(KVTable)
.where(eq(KVTable.key, cursorKey))
.get()
.pipe(Effect.orDie)
const total = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursorValue = typeof cursor?.value === "string" ? cursor.value : undefined
const migrated =
cursorValue !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursorValue}`))
?.value ?? 0)
: 0
return {
status: completed ? ("completed" as const) : running ? ("running" as const) : ("required" as const),
completed: completed ? total : migrated,
total,
}
}).pipe(Effect.orDie)
}
export function run(): Effect.Effect<Result, never, Database.Service> {
return lock.withPermit(
Effect.gen(function* () {
const { db } = yield* Database.Service
if (yield* hasKey(db, completedKey)) return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
running = true
const migrate = Effect.gen(function* () {
while (true) {
const cursor = yield* db
.select({ value: KVTable.value })
.from(KVTable)
.where(eq(KVTable.key, cursorKey))
.get()
.pipe(Effect.orDie)
const cursorValue = typeof cursor?.value === "string" ? cursor.value : undefined
const nextID = yield* db.get<{ id: string }>(
cursorValue === undefined
? sql`SELECT id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
)
SELECT
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(EventTable).where(eq(EventTable.aggregate_id, next.id)).run()
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx.run(sql`
INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data)
VALUES (${message.id}, ${message.session_id}, ${message.type}, ${message.seq}, ${message.time_created}, ${message.time_updated}, ${JSON.stringify(message.data)})
`),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run()
yield* tx
.insert(KVTable)
.values({ key: cursorKey, value: next.id })
.onConflictDoUpdate({ target: KVTable.key, set: { value: next.id, time_updated: Date.now() } })
.run()
}),
)
.pipe(Effect.orDie)
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: completedKey, value: true })
.onConflictDoUpdate({ target: KVTable.key, set: { value: true, time_updated: Date.now() } })
.run()
yield* tx.delete(KVTable).where(eq(KVTable.key, cursorKey)).run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
})
return yield* migrate.pipe(
Effect.ensuring(
Effect.sync(() => {
running = false
}),
),
)
}).pipe(Effect.orDie),
)
}
function row(
source: SourceMessage,
message: {
readonly id: string
readonly type: SessionMessage.Type
readonly time: { readonly created: number }
readonly [key: string]: unknown
},
): TransformResult["messages"][number] {
const { id, type, ...data } = message
return {
id,
session_id: source.session_id,
type,
seq: 0,
time_created: source.time_created,
time_updated: source.time_updated,
data,
}
}
function migrateTool(part: typeof SessionV1.ToolPart.Type, fallback: number) {
const base = {
type: "tool" as const,
id: part.callID,
name: part.tool,
...(part.metadata ? { providerState: part.metadata } : {}),
}
if (part.state.status === "completed")
return {
...base,
state: {
status: "completed",
input: part.state.input,
content:
part.state.time.compacted === undefined
? [
{ type: "text", text: part.state.output },
...(part.state.attachments ?? []).map((file) => ({
type: "file" as const,
uri: file.url,
mime: file.mime,
...(file.filename ? { name: file.filename } : {}),
})),
]
: [{ type: "text", text: "[Old tool result content cleared]" }],
metadata: part.state.metadata,
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
if (part.state.status === "error")
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.execution", message: part.state.error },
...(typeof part.state.metadata?.output === "string"
? { content: [{ type: "text", text: part.state.metadata.output }] }
: {}),
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.interrupted", message: "Tool execution was interrupted before V2 migration" },
...(part.state.status === "running" && part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.status === "running" ? part.state.time.start : fallback },
}
}
function migrateError(error: NonNullable<(typeof SessionV1.Assistant.Type)["error"]>) {
const message =
"message" in error.data
? error.data.message
: error.name === "MessageOutputLengthError"
? "The model exceeded its output limit"
: error.name
const type =
error.name === "ProviderAuthError"
? "provider.auth"
: error.name === "ContentFilterError"
? "provider.content-filter"
: error.name === "ContextOverflowError"
? "provider.invalid-request"
: error.name === "StructuredOutputError" || error.name === "MessageOutputLengthError"
? "provider.invalid-output"
: error.name === "MessageAbortedError"
? "aborted"
: error.name === "APIError"
? "provider.error"
: "unknown"
return { type, message }
}
function normalizeFinish(finish: string | undefined) {
if (!finish) return undefined
return (
(["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const).find(
(value) => value === finish,
) ?? "unknown"
)
}
function migrateFile(part: SessionV1.FilePart) {
if (!part.url.startsWith("data:")) return []
const comma = part.url.indexOf(",")
if (comma < 0) return []
const header = part.url.slice(0, comma)
const payload = part.url.slice(comma + 1)
const data = header.endsWith(";base64")
? Buffer.from(payload, "base64").toString("base64")
: Buffer.from(decodeURIComponent(payload)).toString("base64")
return [
{
data,
mime: part.mime,
source:
part.source?.type === "resource" ? { type: "uri" as const, uri: part.source.uri } : { type: "inline" as const },
...(part.filename ? { name: part.filename } : {}),
...(part.source
? { mention: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end } }
: {}),
},
]
}
function unavailableFile(part: SessionV1.FilePart) {
const label = part.filename ?? (part.source?.type === "resource" ? part.source.uri : part.url)
return `[Attachment unavailable after migration: ${label} (${part.mime})]`
}
function syntheticID(source: string, used: Set<string>) {
const prefix = source.slice(0, 16)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
for (let salt = 0; ; salt++) {
const hex = new Bun.CryptoHasher("sha256").update(`v1-synthetic:${source}${salt ? `:${salt}` : ""}`).digest("hex")
let value = BigInt(`0x${hex}`)
let suffix = ""
while (suffix.length < 14) {
suffix = alphabet[Number(value % 62n)] + suffix
value /= 62n
}
const id = prefix + suffix
if (used.has(id)) continue
used.add(id)
return id
}
}
function serializeRecent(
messages: ReadonlyArray<{ row: SourceMessage; value: typeof SessionV1.Info.Type }>,
parts: Map<string, Array<{ row: SourcePart; value: typeof SessionV1.Part.Type }>>,
) {
return messages
.flatMap((message) => {
const owned = parts.get(message.row.id)?.map((part) => part.value) ?? []
if (message.value.role === "user")
return [
`[User]: ${owned
.filter((part) => part.type === "text" && !part.ignored)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")}`,
]
return owned.flatMap((part) =>
part.type === "text"
? [`[Assistant]: ${part.text}`]
: part.type === "reasoning" && part.text
? [`[Assistant reasoning]: ${part.text}`]
: [],
)
})
.join("\n\n")
}
function hasKey(db: Database.Interface["db"], key: string) {
return db
.select({ key: KVTable.key })
.from(KVTable)
.where(eq(KVTable.key, key))
.get()
.pipe(
Effect.map((row) => row !== undefined),
Effect.orDie,
)
}
function hasLegacySessions(db: Database.Interface["db"]) {
return db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`).pipe(
Effect.map((row) => row !== undefined),
Effect.orDie,
)
}
+1 -1
View File
@@ -15,7 +15,7 @@ export const EventTable = sqliteTable(
.notNull()
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
seq: integer().notNull(),
created: integer().notNull().default(0),
created: integer().notNull(),
type: text().notNull(),
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
},
+69 -54
View File
@@ -18,6 +18,7 @@ import { SessionMessageTable, SessionTable } from "./session/sql"
import { SessionSchema } from "./session/schema"
import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
import { Agent } from "./agent"
import { SessionV1 } from "./v1/session"
import { Money } from "@opencode-ai/schema/money"
import { App } from "./app"
import { Slug } from "./util/slug"
@@ -220,8 +221,14 @@ export interface Interface {
after?: number
follow?: boolean
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
readonly switchAgent: (input: {
sessionID: SessionSchema.ID
agent: Agent.ID
}) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: {
sessionID: SessionSchema.ID
model: Model.Ref
}) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly move: (input: {
sessionID: SessionSchema.ID
@@ -357,45 +364,45 @@ const layer = Layer.effect(
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
const project = yield* projects.resolve(location.directory)
yield* persistProject(project)
const projected = yield* bus
.publish(
SessionEvent.Created,
{
sessionID,
slug: Slug.create(),
version: app.version,
projectID: project.id,
parentID: input.parentID,
location,
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
title: input.title,
agent: input.agent,
model: input.model
? {
id: Model.ID.make(input.model.id),
providerID: input.model.providerID,
variant: input.model.variant,
}
: undefined,
},
{ location },
)
.pipe(
Effect.as({ type: "created" } as const),
Effect.catchDefect((defect) => {
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
return Effect.die(defect)
const now = Date.now()
const info = SessionV1.SessionInfo.make({
id: sessionID,
slug: Slug.create(),
version: app.version,
projectID: project.id,
parentID: input.parentID,
directory: location.directory,
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
workspaceID: location.workspaceID ? Workspace.ID.make(location.workspaceID) : undefined,
title: input.title,
agent: input.agent,
model: input.model
? {
id: Model.ID.make(input.model.id),
providerID: input.model.providerID,
variant: input.model.variant,
}
// Concurrent creation lost the projection race. The existing Session identity wins.
return store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
),
)
}),
)
: undefined,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: now, updated: now },
})
const projected = yield* bus.publish(SessionV1.Event.Created, { sessionID, info }, { location }).pipe(
Effect.as({ type: "created" } as const),
Effect.catchDefect((defect) => {
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
return Effect.die(defect)
}
// Concurrent creation lost the projection race. The existing Session identity wins.
return store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
),
)
}),
)
if (projected.type === "existing") return projected.session
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
return yield* result.get(sessionID).pipe(Effect.orDie)
@@ -550,7 +557,8 @@ const layer = Layer.effect(
const session = yield* result.get(input.sessionID)
// A staged revert must be committed before admitting new input so the prompt
// continues from the reverted boundary rather than stale post-boundary history.
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
if (session.revert)
yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
// Resolved lazily so prompt admission only boots location services when an
// image attachment actually needs the resizer.
const image = Image.Service.pipe(Effect.provide(locations.get(session.location)))
@@ -730,23 +738,23 @@ const layer = Layer.effect(
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
if (current.location.directory === directory && current.location.workspaceID === input.workspaceID) return
if (
current.location.directory === directory &&
current.location.workspaceID === input.workspaceID
)
return
const project = yield* projects.resolve(directory)
yield* persistProject(project)
if ((yield* execution.active).has(input.sessionID)) {
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
}
yield* bus.publish(
SessionEvent.Moved,
{
sessionID: input.sessionID,
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
},
{ location: current.location },
)
yield* bus.publish(SessionEvent.Moved, {
sessionID: input.sessionID,
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
})
}),
compact: Effect.fn("Session.compact")(function* (input) {
yield* result.get(input.sessionID)
@@ -827,7 +835,9 @@ const layer = Layer.effect(
}),
),
),
interrupt: Effect.fn("Session.interrupt")((sessionID) => Effect.uninterruptible(execution.interrupt(sessionID))),
interrupt: Effect.fn("Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)),
),
revert: {
stage: Effect.fn("Session.revert.stage")(function* (input) {
const session = yield* result.get(input.sessionID)
@@ -926,7 +936,12 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
.join("\n"),
)
: resolved.bytes
const normalized = yield* normalizeImageAttachment(input, Buffer.from(content).toString("base64"), mime, image)
const normalized = yield* normalizeImageAttachment(
input,
Buffer.from(content).toString("base64"),
mime,
image,
)
return FileAttachment.create({
data: normalized.data,
mime: normalized.mime,
@@ -141,7 +141,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return Effect.gen(function* () {
yield* SessionEvent.All.match(event, {
"session.created": () => Effect.void,
"session.usage.updated": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {

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