mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 01:43:27 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b20a31bb8f |
@@ -5,8 +5,27 @@
|
||||
- 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.
|
||||
- Add explicit data operations to that migration where generated DDL is insufficient.
|
||||
- Test the migration against a populated database at the exact `dev` 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.
|
||||
|
||||
## Preserve
|
||||
|
||||
@@ -15,20 +34,35 @@ 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.
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
## Truncate
|
||||
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 these pre-launch V2 tables before applying schema changes:
|
||||
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.
|
||||
|
||||
- `event`
|
||||
- `event_sequence`
|
||||
- `session_message`
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Message Backfill
|
||||
|
||||
@@ -36,9 +70,23 @@ 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`.
|
||||
|
||||
@@ -46,15 +94,122 @@ 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.
|
||||
|
||||
@@ -64,9 +219,13 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Drop
|
||||
|
||||
@@ -74,6 +233,7 @@ 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`.
|
||||
|
||||
@@ -103,16 +263,25 @@ 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.
|
||||
|
||||
## Verification
|
||||
## Execution
|
||||
|
||||
The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
|
||||
credentials, permissions, shares, and workspaces. After migration, it should verify:
|
||||
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.
|
||||
|
||||
- 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.
|
||||
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.
|
||||
|
||||
@@ -45,10 +45,6 @@ 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) {
|
||||
|
||||
@@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression"
|
||||
const sessionID = "ses_context_resize_regression"
|
||||
const title = "Context resize regression"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
|
||||
const followingTextID = `${id("msg_assistant", 10)}:text:0`
|
||||
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
|
||||
const followingTextID = "prt_0104_text"
|
||||
|
||||
type Message = {
|
||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||
@@ -263,7 +263,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
|
||||
),
|
||||
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
|
||||
{
|
||||
id: "prt_0104_text",
|
||||
id: followingTextID,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "text",
|
||||
@@ -295,7 +295,7 @@ function contextTool(
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "tool",
|
||||
callID: partID,
|
||||
callID: `call_${partID}`,
|
||||
tool,
|
||||
state: {
|
||||
status,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
status,
|
||||
textPart,
|
||||
title,
|
||||
userID,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
@@ -18,22 +19,18 @@ import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const initialPageSize = 20
|
||||
const historyPageSize = 200
|
||||
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 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 lastAssistant = assistants.at(-1)!
|
||||
const lastPartID = `${assistants.at(-1)!.info.id}:text:0`
|
||||
const userPartID = `${messages.at(-2)!.info.id}:text:0`
|
||||
const lastPartID = assistants.at(-1)!.parts[0]!.id
|
||||
const userPartID = `prt_${userID}_text`
|
||||
const completed = {
|
||||
...lastAssistant.info,
|
||||
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
|
||||
@@ -62,7 +59,6 @@ for (const scenario of scenarios) {
|
||||
retry: 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: project(),
|
||||
provider: {
|
||||
@@ -158,23 +154,15 @@ 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()
|
||||
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)
|
||||
}
|
||||
await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
|
||||
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
|
||||
expect(sequence.slice(0, 3)).toEqual([
|
||||
expect(sequence.slice(0, 4)).toEqual([
|
||||
"messages:start:latest",
|
||||
"messages:end:latest",
|
||||
`message:${userID}`,
|
||||
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
|
||||
])
|
||||
await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
|
||||
initialPageSize / 2,
|
||||
)
|
||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
|
||||
await page.evaluate(() => {
|
||||
;(
|
||||
window as Window & {
|
||||
@@ -186,9 +174,7 @@ for (const scenario of scenarios) {
|
||||
expect(await visibleContentHidden(page)).toBe(false)
|
||||
const beforeHistory = await probeSamples(page)
|
||||
history.resolve()
|
||||
await expect
|
||||
.poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count())
|
||||
.toBeGreaterThan(initialPageSize / 2)
|
||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
|
||||
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
await waitForProbeSamples(page, beforeHistory)
|
||||
@@ -196,7 +182,7 @@ for (const scenario of scenarios) {
|
||||
{ before: undefined, limit: initialPageSize },
|
||||
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
|
||||
])
|
||||
expect(roots).toEqual([])
|
||||
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
||||
|
||||
const message = messageUpdated(scenario.info)
|
||||
const idle = status("idle")
|
||||
|
||||
@@ -10,6 +10,7 @@ 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")).toBe("e2e-ticket")
|
||||
expect(connection.searchParams.get("ticket")).toBeNull()
|
||||
await writeProbe(page)
|
||||
|
||||
await switchTab(page, titleB)
|
||||
@@ -66,6 +66,7 @@ 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:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverKey = "http://127.0.0.1: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 * 100 + partIndex),
|
||||
callID: id("call", index * 10 + partIndex),
|
||||
tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
@@ -235,17 +235,8 @@ function renderable(part: MessagePart) {
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
}
|
||||
|
||||
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()
|
||||
function orderedParts(message: Message) {
|
||||
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
export const fixture = {
|
||||
@@ -299,10 +290,12 @@ export const fixture = {
|
||||
targetMessageIDs: targetMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
targetPartIDs: targetMessages.flatMap(currentPartIDs),
|
||||
expandedShellPartID: targetMessages
|
||||
.flatMap((message) => message.parts)
|
||||
.find((part) => part.tool === "bash")!.callID,
|
||||
targetPartIDs: targetMessages.flatMap((message) =>
|
||||
orderedParts(message)
|
||||
.filter(renderable)
|
||||
.map((part) => part.id),
|
||||
),
|
||||
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ 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,
|
||||
@@ -92,7 +91,6 @@ 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,
|
||||
@@ -119,7 +117,6 @@ 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,
|
||||
@@ -128,19 +125,20 @@ test.describe("smoke: session timeline", () => {
|
||||
})
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
await page.addInitScript(
|
||||
({ server, sourceID, targetID }) => {
|
||||
({ dirBase64, sourceID, targetID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
server,
|
||||
server: "http://127.0.0.1:4096",
|
||||
dirBase64,
|
||||
sessionId,
|
||||
})),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||
)
|
||||
|
||||
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
|
||||
@@ -245,7 +243,6 @@ 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,
|
||||
@@ -254,19 +251,20 @@ test.describe("smoke: session timeline", () => {
|
||||
})
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
await page.addInitScript(
|
||||
({ server, sourceID, targetID }) => {
|
||||
({ dirBase64, sourceID, targetID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
server,
|
||||
server: "http://127.0.0.1:4096",
|
||||
dirBase64,
|
||||
sessionId,
|
||||
})),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||
)
|
||||
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
@@ -324,7 +322,6 @@ 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: "v2",
|
||||
protocol: "v1",
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_model_selection_flow",
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
)
|
||||
}
|
||||
if (path === "/global/health")
|
||||
return config.protocol === "v2" ? json(route, {}) : json(route, { healthy: true })
|
||||
return config.protocol === "v2" ? json(route, {}, undefined, 404) : 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.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||
load: (search, signal) => serverSDK.api.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.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||
load: (search, signal) => serverCtx.sdk.api.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()
|
||||
.currentApi.integration.get({
|
||||
.api.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()
|
||||
.currentApi.integration.oauth.connect({
|
||||
.api.integration.oauth.connect({
|
||||
integrationID: props.provider,
|
||||
methodID: method.id,
|
||||
inputs: inputs ?? {},
|
||||
@@ -816,7 +816,7 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await serverSDK().currentApi.integration.connect.key({
|
||||
await serverSDK().api.integration.connect.key({
|
||||
integrationID: props.provider,
|
||||
location: location(),
|
||||
key: apiKey,
|
||||
@@ -947,7 +947,7 @@ function ProviderConnection(props: {
|
||||
|
||||
setFormStore("error", undefined)
|
||||
const result = await serverSDK()
|
||||
.currentApi.integration.oauth.complete({
|
||||
.api.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()
|
||||
.currentApi.integration.oauth.status({
|
||||
.api.integration.oauth.status({
|
||||
integrationID: props.provider,
|
||||
attemptID: authorization.attemptID,
|
||||
location: location(),
|
||||
|
||||
@@ -69,7 +69,7 @@ export const DialogFork: Component = () => {
|
||||
const dir = base64Encode(sdk().directory)
|
||||
|
||||
sdk()
|
||||
.currentApi.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
||||
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
||||
.then((forked) => {
|
||||
dialog.close()
|
||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||
|
||||
@@ -70,20 +70,10 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async (): Promise<Path | undefined> => {
|
||||
if ((await sdk.protocol) === "v1")
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined)
|
||||
return sdk.currentApi.location
|
||||
if ((await sdk.protocol) !== "v1") return
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((location) => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location.project.directory,
|
||||
directory: location.directory,
|
||||
home: "",
|
||||
}))
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined)
|
||||
},
|
||||
{ initialValue: undefined },
|
||||
@@ -107,7 +97,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.currentApi.file
|
||||
const files = await sdk.api.file
|
||||
.find({
|
||||
location: { directory: base },
|
||||
query: pickerFileSearchQuery(base, value, home()),
|
||||
@@ -137,7 +127,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
return sdk.currentApi.file
|
||||
return sdk.api.file
|
||||
.list({ location: { directory: absolute } })
|
||||
.then((result) =>
|
||||
result.data.map((entry) => ({
|
||||
|
||||
@@ -61,20 +61,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async (): Promise<Path | undefined> => {
|
||||
if ((await sdk.protocol) === "v1")
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined)
|
||||
return sdk.currentApi.location
|
||||
if ((await sdk.protocol) !== "v1") return
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((location) => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location.project.directory,
|
||||
directory: location.directory,
|
||||
home: "",
|
||||
}))
|
||||
.then((result) => result.data)
|
||||
.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 = {
|
||||
currentApi: {
|
||||
api: {
|
||||
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 = {
|
||||
currentApi: {
|
||||
api: {
|
||||
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.currentApi.file
|
||||
const request = args.sdk.api.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.currentApi.file
|
||||
const results = await args.sdk.api.file
|
||||
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||
.then((result) => result.data.map((entry) => entry.path))
|
||||
.catch(() => [])
|
||||
|
||||
@@ -199,7 +199,6 @@ beforeAll(async () => {
|
||||
directory: "/repo/main",
|
||||
client: rootClient,
|
||||
api: rootClient.api,
|
||||
currentApi: rootClient.api,
|
||||
url: "http://localhost:4096",
|
||||
createClient(opts: any) {
|
||||
return clientFor(opts.directory)
|
||||
@@ -333,7 +332,7 @@ describe("prompt submit worktree selection", () => {
|
||||
selected = "/repo/worktree-b"
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(createdClients).toEqual([])
|
||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sessionCreateInputs).toEqual([
|
||||
{
|
||||
@@ -490,6 +489,9 @@ 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,7 +22,6 @@ 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
|
||||
@@ -42,7 +41,7 @@ export type FollowupDraft = {
|
||||
}
|
||||
|
||||
type FollowupSendInput = {
|
||||
api: DirectorySDK["currentApi"]["session"]
|
||||
api: DirectorySDK["api"]["session"]
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
draft: FollowupDraft
|
||||
@@ -160,6 +159,10 @@ 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 []
|
||||
@@ -261,7 +264,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sdk()
|
||||
.currentApi.session.interrupt({ sessionID })
|
||||
.api.session.interrupt({ sessionID })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
@@ -345,16 +348,13 @@ 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 sdk()
|
||||
.currentApi.projectCopy.create({
|
||||
projectID: sync().data.project,
|
||||
strategy: "git_worktree",
|
||||
directory: getDirectory(projectDirectory),
|
||||
location: { directory: projectDirectory },
|
||||
})
|
||||
const createdWorktree = await client.worktree
|
||||
.create({ directory: projectDirectory })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
@@ -363,7 +363,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!createdWorktree) return
|
||||
if (!createdWorktree?.directory) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: language.t("common.requestFailed"),
|
||||
})
|
||||
return
|
||||
}
|
||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
@@ -373,6 +379,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
client = sdk().createClient({
|
||||
directory: sessionDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
serverSync().child(sessionDirectory)
|
||||
}
|
||||
|
||||
@@ -382,7 +392,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
let session = input.info()
|
||||
if (!session && isNewSession) {
|
||||
const created = await sdk()
|
||||
.currentApi.session.create({
|
||||
.api.session.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
@@ -473,10 +483,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
sdk()
|
||||
.currentApi.session.shell({
|
||||
.api.session.shell({
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
agent,
|
||||
model,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -497,7 +509,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const messageID = Identifier.ascending("message")
|
||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||
sdk()
|
||||
.currentApi.session.command({
|
||||
.api.session.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
@@ -594,7 +606,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
void sendFollowupDraft({
|
||||
api: sdk().currentApi.session,
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft,
|
||||
|
||||
@@ -129,13 +129,9 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
return
|
||||
}
|
||||
await serverSDK()
|
||||
.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 })),
|
||||
)
|
||||
.client.auth.remove({ providerID })
|
||||
.then(async () => {
|
||||
await serverSDK().client.global.dispose()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
|
||||
@@ -125,17 +125,10 @@ export const SettingsProvidersV2: Component<{
|
||||
await disableProvider(providerID, name)
|
||||
return
|
||||
}
|
||||
const location = props.directory() ? { directory: props.directory() } : undefined
|
||||
await serverSdk()
|
||||
.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 }),
|
||||
),
|
||||
)
|
||||
.client.auth.remove({ providerID })
|
||||
.then(async () => {
|
||||
await serverSdk().client.global.dispose()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
|
||||
@@ -17,7 +17,6 @@ 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+`"
|
||||
@@ -183,6 +182,8 @@ 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",
|
||||
@@ -240,8 +241,18 @@ 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()
|
||||
.currentApi.pty.update({
|
||||
.api.pty.update({
|
||||
ptyID: id,
|
||||
location: { directory },
|
||||
size: { cols, rows },
|
||||
@@ -522,8 +533,17 @@ 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()
|
||||
.currentApi.pty.get({ ptyID: id, location: { directory } })
|
||||
.api.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
|
||||
@@ -533,23 +553,33 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const connectToken = async () => {
|
||||
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
|
||||
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 retry = (err: unknown) => {
|
||||
@@ -579,16 +609,23 @@ 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.currentApi.session.rename({ sessionID: value.id, title })
|
||||
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
||||
} catch (err) {
|
||||
const current = session()
|
||||
const currentCtx = props.serverCtx()
|
||||
|
||||
@@ -192,7 +192,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||
},
|
||||
({ route, sdk }) =>
|
||||
sdk.currentApi.session
|
||||
sdk.api.session
|
||||
.get({ sessionID: route.sessionId })
|
||||
.then(normalizeSessionInfo)
|
||||
.catch(() => {}),
|
||||
|
||||
@@ -124,7 +124,7 @@ export const createDirSyncContext = (
|
||||
fetch: async (count = 10) => {
|
||||
const [store, setStore] = current()
|
||||
setStore("limit", (value) => value + count)
|
||||
const response = await serverSDK.currentApi.session.list({ directory, limit: store.limit, order: "desc" })
|
||||
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
|
||||
const sessions = response.data
|
||||
.map(normalizeSessionInfo)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
|
||||
@@ -81,15 +81,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
normalizeDir: path.normalizeDir,
|
||||
list: (dir) =>
|
||||
sdk()
|
||||
.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,
|
||||
})),
|
||||
),
|
||||
.client.file.list({ path: dir })
|
||||
.then((x) => x.data ?? []),
|
||||
onError: (message) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
@@ -188,10 +181,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
setLoading(file)
|
||||
|
||||
const promise = sdk()
|
||||
.currentApi.file.read({ path: file, location: { directory } })
|
||||
.then((data) => {
|
||||
.client.file.read({ path: file })
|
||||
.then((x) => {
|
||||
if (scope() !== directory) return
|
||||
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
|
||||
const content = x.data
|
||||
setLoaded(file, content)
|
||||
|
||||
if (!content) return
|
||||
@@ -212,7 +205,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
|
||||
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||
serverSDK()
|
||||
.currentApi.file.find(
|
||||
.api.file.find(
|
||||
{
|
||||
location: { directory: sdk().directory },
|
||||
query,
|
||||
|
||||
@@ -76,30 +76,9 @@ function directoryState() {
|
||||
}
|
||||
|
||||
describe("bootstrapDirectory", () => {
|
||||
test("uses current MCP endpoints while retaining unsupported v1 directory reads", async () => {
|
||||
test("uses legacy MCP endpoints while refreshing a v1 directory", 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",
|
||||
@@ -141,7 +120,7 @@ describe("bootstrapDirectory", () => {
|
||||
},
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient,
|
||||
api: currentApi,
|
||||
api,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
@@ -162,21 +141,12 @@ describe("bootstrapDirectory", () => {
|
||||
|
||||
describe("query keys", () => {
|
||||
test("partitions identical directories by server scope", () => {
|
||||
const location = {} as Parameters<typeof loadPathQuery>[2]
|
||||
const client = {} as Parameters<typeof loadPathQuery>[3]
|
||||
const client = {} as Parameters<typeof loadPathQuery>[2]
|
||||
const api = {} as CatalogApi
|
||||
const remote = "https://debian.example" as typeof ServerScope.local
|
||||
|
||||
expect([...loadPathQuery(ServerScope.local, "/repo", location, client).queryKey]).toEqual([
|
||||
"local",
|
||||
"/repo",
|
||||
"path",
|
||||
])
|
||||
expect([...loadPathQuery(remote, "/repo", location, client).queryKey]).toEqual([
|
||||
"https://debian.example",
|
||||
"/repo",
|
||||
"path",
|
||||
])
|
||||
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
|
||||
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
|
||||
})
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ import type {
|
||||
CommandInfo,
|
||||
CommandListInput,
|
||||
CommandListOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
ProjectListOutput,
|
||||
@@ -117,7 +115,6 @@ 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"]
|
||||
@@ -142,7 +139,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
serverSDK: OpencodeClient
|
||||
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
|
||||
serverAPI: CatalogApi & { readonly project: ProjectApi }
|
||||
protocol?: Promise<ServerProtocol>
|
||||
scope: ServerScope
|
||||
requestFailedTitle: string
|
||||
@@ -155,12 +152,9 @@ export async function bootstrapGlobal(input: {
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadProvidersQuery(input.scope, null, input.serverAPI),
|
||||
),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadPathQuery(input.scope, null, input.serverAPI.location, input.serverSDK, input.protocol),
|
||||
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
||||
),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||
@@ -225,11 +219,17 @@ 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,38 +256,54 @@ export const loadAgentsQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
sdk: AgentListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "agents"],
|
||||
queryFn: () =>
|
||||
retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
|
||||
return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
|
||||
}),
|
||||
})
|
||||
|
||||
export const loadCommands = (
|
||||
directory: string,
|
||||
api: CommandListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
): Promise<CommandInfo[]> =>
|
||||
retry(() => api.list({ location: { directory } }).then((result) => result.data))
|
||||
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)
|
||||
})
|
||||
|
||||
export const loadPathQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string | null,
|
||||
api: LocationApi,
|
||||
sdk: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions<Path>({
|
||||
queryKey: [scope, directory, "path"],
|
||||
queryFn: async () => {
|
||||
if ((await protocol) === "v1")
|
||||
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
|
||||
return retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location.project.directory,
|
||||
directory: location.directory,
|
||||
home: "",
|
||||
}))
|
||||
if ((await protocol) !== "v1")
|
||||
return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" }
|
||||
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -295,11 +311,16 @@ export const loadReferencesQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: ReferenceListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions<ReferenceInfo[]>({
|
||||
queryKey: [scope, directory, "references"] as const,
|
||||
queryFn: () =>
|
||||
retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
|
||||
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(() => []),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
@@ -318,7 +339,6 @@ export async function bootstrapDirectory(input: {
|
||||
readonly reference: ReferenceListApi
|
||||
readonly session: SessionApi
|
||||
readonly vcs: VcsApi
|
||||
readonly location: LocationApi
|
||||
}
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
@@ -353,7 +373,7 @@ export async function bootstrapDirectory(input: {
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
||||
.then((data) => input.setStore("agent", data)),
|
||||
() =>
|
||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||
@@ -392,9 +412,7 @@ export async function bootstrapDirectory(input: {
|
||||
!seededPath &&
|
||||
(() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(
|
||||
loadPathQuery(input.scope, input.directory, input.api.location, input.sdk, input.protocol),
|
||||
)
|
||||
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol))
|
||||
.then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
@@ -410,12 +428,12 @@ export async function bootstrapDirectory(input: {
|
||||
}),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
loadCommands(input.directory, input.api.command).then((commands) =>
|
||||
loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
|
||||
input.setStore("command", commands),
|
||||
)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadReferencesQuery(input.scope, input.directory, input.api.reference),
|
||||
loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
@@ -493,16 +511,16 @@ export async function bootstrapDirectory(input: {
|
||||
input.mcp &&
|
||||
(() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadMcpQuery(input.scope, input.directory, input.api.mcp),
|
||||
loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
||||
)),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp),
|
||||
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
||||
)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api))
|
||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
|
||||
.catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { type DirectorySDK, useServerSDK } from "./server-sdk"
|
||||
import { type ServerSDK, useServerSDK } from "./server-sdk"
|
||||
|
||||
export type { DirectorySDK }
|
||||
export type DirectorySDK = ReturnType<ServerSDK["ensureDirSdkContext"]>
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
|
||||
@@ -13,7 +13,6 @@ 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 type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
@@ -193,6 +192,11 @@ 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,
|
||||
@@ -260,12 +264,18 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
try {
|
||||
const events = eventApi.event.subscribe({ signal: attempt.signal })
|
||||
const kind = await protocol
|
||||
const events =
|
||||
kind === "v1"
|
||||
? (await eventSdk.global.event({ signal: attempt.signal })).stream
|
||||
: eventApi.event.subscribe({ signal: attempt.signal })
|
||||
let yielded = Date.now()
|
||||
for await (const event of events) {
|
||||
streamErrorLogged = false
|
||||
const directory = event.location?.directory ?? "global"
|
||||
const payload = adaptServerEvent(event)
|
||||
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)
|
||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
@@ -354,24 +364,8 @@ 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
|
||||
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
|
||||
readonly url: string
|
||||
createClient: ServerSDKBase["createClient"]
|
||||
}
|
||||
|
||||
export type ServerSDK = ServerSDKBase & {
|
||||
ensureDirSdkContext: (directory: string) => DirectorySDK
|
||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
}
|
||||
|
||||
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
|
||||
@@ -403,7 +397,11 @@ export function useServerProtocol() {
|
||||
return createMemo(() => serverSDK().protocolKind())
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
const client = serverSDK.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
@@ -421,7 +419,6 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): Direc
|
||||
protocol: serverSDK.protocol,
|
||||
directory,
|
||||
client,
|
||||
currentApi: serverSDK.currentApi,
|
||||
api: createCompatibleApi({
|
||||
protocol: serverSDK.protocol,
|
||||
current: serverSDK.currentApi,
|
||||
|
||||
@@ -265,34 +265,6 @@ 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 () => {
|
||||
|
||||
@@ -563,7 +563,7 @@ export function createServerSession(
|
||||
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||
projectSource: true,
|
||||
cursor: response.cursor.next ?? undefined,
|
||||
complete: !response.cursor.next,
|
||||
complete: response.data.length === 0,
|
||||
}
|
||||
}
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
@@ -683,14 +683,7 @@ export function createServerSession(
|
||||
? (() => {
|
||||
const incoming = new Map(page.source.map((message) => [message.id, message]))
|
||||
const existing = data.session_message[sessionID] ?? []
|
||||
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 current = existing.filter((message) => !incoming.has(message.id))
|
||||
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),
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||
import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } 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,7 +59,6 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession, type ServerSession } from "./server-session"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -95,6 +94,8 @@ 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"]>,
|
||||
@@ -104,6 +105,7 @@ 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])))
|
||||
@@ -114,6 +116,8 @@ 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>,
|
||||
@@ -123,6 +127,14 @@ 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) =>
|
||||
@@ -174,13 +186,16 @@ function makeQueryOptionsApi(
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
||||
providers: (directory: PathKey | null) =>
|
||||
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
|
||||
path: (directory: PathKey | null) =>
|
||||
loadPathQuery(scope, directory, serverAPI.location, directory ? sdkFor(directory) : serverSDK(), protocol),
|
||||
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),
|
||||
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)),
|
||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||
}
|
||||
@@ -189,7 +204,6 @@ 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")
|
||||
|
||||
@@ -210,11 +224,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return sdk
|
||||
}
|
||||
|
||||
const session = createServerSession(serverSDK.client, serverSDK.currentApi.session, serverSDK.currentApi.message)
|
||||
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
|
||||
protocol: serverSDK.protocol,
|
||||
})
|
||||
const queryOptionsApi = makeQueryOptionsApi(
|
||||
serverSDK.scope,
|
||||
() => serverSDK.client,
|
||||
serverSDK.currentApi,
|
||||
serverSDK.api,
|
||||
sdkFor,
|
||||
serverSDK.protocol,
|
||||
)
|
||||
@@ -225,7 +241,19 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const activeSessionsQuery = useQuery(() =>
|
||||
loadActiveSessionsQuery(serverSDK.scope, {
|
||||
active: async () => {
|
||||
const active = await serverSDK.currentApi.session.active()
|
||||
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()
|
||||
seedActiveSessionStatuses(session, active)
|
||||
for (const sessionID of Object.keys(active)) {
|
||||
void session.resolve(sessionID).catch(() => undefined)
|
||||
@@ -294,7 +322,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
queryFn: async () => {
|
||||
await bootstrapGlobal({
|
||||
serverSDK: serverSDK.client,
|
||||
serverAPI: serverSDK.currentApi,
|
||||
serverAPI: serverSDK.api,
|
||||
protocol: serverSDK.protocol,
|
||||
scope: serverSDK.scope,
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
@@ -335,7 +363,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
void bootstrapInstance(directory)
|
||||
},
|
||||
onMcp: (directory, setStore) => {
|
||||
void loadCommands(directory, serverSDK.currentApi.command)
|
||||
void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol)
|
||||
.then((commands) => setStore("command", commands))
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -388,7 +416,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
.fetchQuery({
|
||||
...queryOptionsApi.sessions(key),
|
||||
queryFn: () =>
|
||||
loadRootSessions({ api: serverSDK.currentApi.session, directory, limit })
|
||||
serverSDK.protocol
|
||||
.then((protocol) =>
|
||||
protocol === "v1"
|
||||
? loadRootSessionsV1({ client: sdkFor(directory), directory, limit })
|
||||
: loadRootSessions({ api: serverSDK.api.session, directory, limit }),
|
||||
)
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
@@ -458,7 +491,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
sdk,
|
||||
api: serverSDK.currentApi,
|
||||
api: serverSDK.api,
|
||||
store: child[0],
|
||||
setStore: child[1],
|
||||
vcsCache: cache,
|
||||
@@ -659,35 +692,27 @@ 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 () => {
|
||||
await serverSDK.currentApi.mcp.connect({ server: name, location: { directory: key } })
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
await sdk.mcp.connect({ name })
|
||||
return
|
||||
}
|
||||
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
|
||||
},
|
||||
disconnect: async () => {
|
||||
await serverSDK.currentApi.mcp.disconnect({ server: name, location: { directory: key } })
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
await sdk.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
|
||||
},
|
||||
authenticate: async () => {
|
||||
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)
|
||||
await sdk.mcp.auth.authenticate({ name })
|
||||
},
|
||||
refresh: async () => {
|
||||
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
||||
|
||||
@@ -248,12 +248,20 @@ function createWorkspaceTerminalSession(
|
||||
setStore("all", index, (item) => ({ ...item, ...pty }))
|
||||
}
|
||||
const doUpdate = async () => {
|
||||
await sdk.currentApi.pty.update({
|
||||
ptyID: pty.id,
|
||||
location,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
})
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
doUpdate().catch((error: unknown) => {
|
||||
if (previous) {
|
||||
@@ -268,13 +276,20 @@ function createWorkspaceTerminalSession(
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
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
|
||||
})
|
||||
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
|
||||
})
|
||||
if (!data?.id) return
|
||||
|
||||
const active = store.active === pty.id
|
||||
@@ -311,9 +326,10 @@ function createWorkspaceTerminalSession(
|
||||
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
|
||||
|
||||
const doCreate = async () => {
|
||||
return sdk.currentApi.pty
|
||||
.create({ location, title: defaultTitle(nextNumber) })
|
||||
.then((result) => result.data)
|
||||
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
|
||||
}
|
||||
doCreate()
|
||||
.then((data) => {
|
||||
@@ -417,7 +433,11 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
}
|
||||
|
||||
await sdk.currentApi.pty.remove({ ptyID: id, location }).catch((error: unknown) => {
|
||||
const removePromise =
|
||||
(await sdk.protocol) === "v1"
|
||||
? sdk.client.pty.remove({ ptyID: id })
|
||||
: sdk.api.pty.remove({ ptyID: id, location })
|
||||
await removePromise.catch((error: unknown) => {
|
||||
console.error("Failed to close terminal", error)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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 { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { Session } from "@/types"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
@@ -1189,13 +1189,10 @@ 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().currentApi.project.current({ location: { directory: root } }),
|
||||
project?.id ?? serverSDK().api.project.current({ location: { directory: root } }),
|
||||
)
|
||||
.then((value) => (typeof value === "string" ? value : value.id))
|
||||
.then(async (projectID) => {
|
||||
await serverSDK().currentApi.projectCopy.refresh({ projectID, location: { directory: root } })
|
||||
return serverSDK().currentApi.project.directories({ projectID, location: { directory: root } })
|
||||
})
|
||||
.then((projectID) => serverSDK().api.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])
|
||||
@@ -1240,7 +1237,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
await Promise.all(
|
||||
dirs.map(async (item) => ({
|
||||
path: { directory: item },
|
||||
session: await listAllSessions(serverSDK().currentApi.session, {
|
||||
session: await listAllSessions(serverSDK().api.session, {
|
||||
directory: item,
|
||||
parentID: null,
|
||||
order: "desc",
|
||||
@@ -1406,19 +1403,16 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
|
||||
setBusy(directory, true)
|
||||
|
||||
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
|
||||
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
|
||||
})
|
||||
|
||||
setBusy(directory, false)
|
||||
|
||||
@@ -1467,9 +1461,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
})
|
||||
const dismiss = () => toaster.dismiss(progress)
|
||||
|
||||
const sessions = await listAllSessions(serverSDK().currentApi.session, { directory, order: "desc" }).catch(
|
||||
() => [],
|
||||
)
|
||||
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
|
||||
|
||||
clearWorkspaceTerminals(
|
||||
directory,
|
||||
@@ -1603,7 +1595,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
const sessions = await listAllSessions(serverSDK().currentApi.session, {
|
||||
const sessions = await listAllSessions(serverSDK().api.session, {
|
||||
directory: props.directory,
|
||||
order: "desc",
|
||||
}).catch(() => [])
|
||||
@@ -1843,26 +1835,20 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
|
||||
const createWorkspace = async (project: LocalProject) => {
|
||||
clearSidebarHoverState()
|
||||
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
|
||||
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
|
||||
})
|
||||
|
||||
if (!created?.directory) return
|
||||
|
||||
setWorkspaceName(created.directory, getFilename(created.directory), project.id)
|
||||
setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch)
|
||||
|
||||
const local = project.worktree
|
||||
const key = pathKey(created.directory)
|
||||
|
||||
@@ -1724,7 +1724,7 @@ export default function Page() {
|
||||
setFollowup("failed", input.sessionID, undefined)
|
||||
|
||||
const ok = await sendFollowupDraft({
|
||||
api: sdk().currentApi.session,
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft: item,
|
||||
@@ -1820,13 +1820,13 @@ export default function Page() {
|
||||
const halt = (sessionID: string) =>
|
||||
busy(sessionID)
|
||||
? sdk()
|
||||
.currentApi.session.interrupt({ sessionID })
|
||||
.api.session.interrupt({ sessionID })
|
||||
.catch(() => {})
|
||||
: Promise.resolve()
|
||||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
const session = sdk().currentApi.session
|
||||
const session = sdk().api.session
|
||||
const target = sync()
|
||||
const last = target.session.get(input.sessionID)?.revert
|
||||
const value = draft(input.messageID)
|
||||
@@ -1849,7 +1849,7 @@ export default function Page() {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const session = sdk().currentApi.session
|
||||
const session = sdk().api.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()
|
||||
.currentApi.file.read({ path, location: { directory: sdk().directory } })
|
||||
.then((content) => ({ type: "text" as const, content: new TextDecoder().decode(content) }))
|
||||
.client.file.read({ path })
|
||||
.then((x) => x.data)
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to read file", { path, error })
|
||||
return undefined
|
||||
|
||||
@@ -680,7 +680,7 @@ export function MessageTimeline(props: {
|
||||
|
||||
const titleMutation = useMutation(() => ({
|
||||
mutationFn: (input: { id: string; title: string }) =>
|
||||
sdk().currentApi.session.rename({ sessionID: input.id, title: input.title }),
|
||||
sdk().api.session.rename({ sessionID: input.id, title: input.title }),
|
||||
onSuccess: (_, input) => {
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
@@ -854,7 +854,7 @@ export function MessageTimeline(props: {
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
const result = await sdk()
|
||||
.currentApi.session.remove({ sessionID })
|
||||
.api.session.remove({ sessionID })
|
||||
.then(() => true)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
|
||||
@@ -306,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const session = sdk().currentApi.session
|
||||
const session = sdk().api.session
|
||||
const directory = sdk().directory
|
||||
const promptSession = prompt.capture()
|
||||
const revert = info()?.revert?.messageID
|
||||
@@ -334,7 +334,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const session = sdk().currentApi.session
|
||||
const session = sdk().api.session
|
||||
const messages = userMessages()
|
||||
const promptSession = prompt.capture()
|
||||
|
||||
@@ -366,7 +366,19 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
await sdk().currentApi.session.compact({ sessionID })
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
const fork = () => {
|
||||
|
||||
@@ -102,8 +102,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||
|
||||
const readFile = async (path: string) =>
|
||||
sdk()
|
||||
.currentApi.file.read({ path, location: { directory: sdk().directory } })
|
||||
.then((content) => ({ type: "text" as const, content: new TextDecoder().decode(content) }))
|
||||
.client.file.read({ path })
|
||||
.then((x) => x.data)
|
||||
.catch((error) => {
|
||||
console.debug("[session-review-v2] failed to read file", { path, error })
|
||||
return undefined
|
||||
|
||||
@@ -3,31 +3,6 @@ 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 } },
|
||||
|
||||
@@ -196,7 +196,7 @@ function userMessage(
|
||||
|
||||
function userParts(sessionID: string, message: SessionMessageUser): Part[] {
|
||||
return [
|
||||
...(message.text ? [textPart(sessionID, message.id, 0, message.text)] : []),
|
||||
textPart(sessionID, message.id, 0, message.text),
|
||||
...(message.files ?? []).map(
|
||||
(file, index): FilePart => ({
|
||||
id: `${message.id}:file:${index}`,
|
||||
|
||||
@@ -61,7 +61,7 @@ for (const item of targets) {
|
||||
name: "parcel-watcher-binding",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
|
||||
contents: `import binding from ${JSON.stringify(parcelWatcherPackage)}; export default () => binding`,
|
||||
contents: `export default () => require(${JSON.stringify(parcelWatcherPackage)})`,
|
||||
loader: "js",
|
||||
}))
|
||||
},
|
||||
|
||||
@@ -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, Logger, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { Effect, FileSystem, 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", "prod"].includes(OPENCODE_CHANNEL) ||
|
||||
(["latest", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
|
||||
? "opencode.db"
|
||||
@@ -108,9 +108,7 @@ 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"
|
||||
@@ -128,7 +126,6 @@ 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(
|
||||
|
||||
@@ -283,6 +283,26 @@ 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
|
||||
@@ -1538,19 +1558,33 @@ export interface DebugApi<E = never> {
|
||||
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint27_0Input = {
|
||||
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 = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
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_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_1Input = {
|
||||
export type Endpoint28_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly query: string
|
||||
readonly providerID?: WebSearch.ID | undefined
|
||||
}
|
||||
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 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 interface WebsearchApi<E = never> {
|
||||
readonly providers: WebsearchProvidersOperation<E>
|
||||
@@ -1585,5 +1619,6 @@ 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>
|
||||
}
|
||||
|
||||
@@ -215,10 +215,12 @@ 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"
|
||||
|
||||
@@ -1217,22 +1219,32 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
|
||||
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
|
||||
})
|
||||
|
||||
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
|
||||
preserveEffect<Endpoint27_0Output>()(
|
||||
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>()(
|
||||
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
|
||||
preserveEffect<Endpoint27_1Output>()(
|
||||
const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
|
||||
preserveEffect<Endpoint28_1Output>()(
|
||||
raw["websearch.query"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { query: input["query"], providerID: input["providerID"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint27_0(raw),
|
||||
query: Endpoint27_1(raw),
|
||||
const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint28_0(raw),
|
||||
query: Endpoint28_1(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
@@ -1263,7 +1275,8 @@ const adaptClient = (raw: RawClient) => ({
|
||||
projectCopy: adaptGroup24(raw["server.projectCopy"]),
|
||||
vcs: adaptGroup25(raw["server.vcs"]),
|
||||
debug: adaptGroup26(raw["server.debug"]),
|
||||
websearch: adaptGroup27(raw["server.websearch"]),
|
||||
migration: adaptGroup27(raw["server.migration"]),
|
||||
websearch: adaptGroup28(raw["server.websearch"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
||||
@@ -211,6 +211,8 @@ import type {
|
||||
DebugLocationListOutput,
|
||||
DebugLocationEvictInput,
|
||||
DebugLocationEvictOutput,
|
||||
MigrationV1StatusOutput,
|
||||
MigrationV1RunOutput,
|
||||
WebsearchProvidersInput,
|
||||
WebsearchProvidersOutput,
|
||||
WebsearchQueryInput,
|
||||
@@ -492,7 +494,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -718,7 +720,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/context`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 500, 400, 401],
|
||||
declaredStatuses: [404, 500, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -730,7 +732,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -793,7 +795,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, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -826,7 +828,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -1768,6 +1770,32 @@ 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>(
|
||||
|
||||
@@ -313,164 +313,6 @@ 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 = {
|
||||
@@ -569,6 +411,27 @@ 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
|
||||
@@ -862,26 +725,6 @@ 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
|
||||
@@ -1537,96 +1380,6 @@ 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
|
||||
@@ -1904,23 +1657,6 @@ 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
|
||||
@@ -1998,41 +1734,6 @@ 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
|
||||
@@ -2064,56 +1765,6 @@ 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 = {
|
||||
@@ -2137,12 +1788,6 @@ export type SessionMessageInfo =
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
|
||||
export type SessionV1ToolState =
|
||||
| SessionV1ToolStatePending
|
||||
| SessionV1ToolStateRunning
|
||||
| SessionV1ToolStateCompleted
|
||||
| SessionV1ToolStateError
|
||||
|
||||
export type FormCreated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2153,6 +1798,7 @@ export type FormCreated = {
|
||||
}
|
||||
|
||||
export type SessionEventDurable =
|
||||
| SessionCreated
|
||||
| SessionAgentSelected
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
@@ -2197,43 +1843,6 @@ 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
|
||||
@@ -2241,12 +1850,6 @@ export type V2Event =
|
||||
| CatalogUpdated
|
||||
| AgentUpdated
|
||||
| SessionCreated
|
||||
| SessionUpdated
|
||||
| SessionDeleted1
|
||||
| MessageUpdated
|
||||
| MessageRemoved
|
||||
| MessagePartUpdated
|
||||
| MessagePartRemoved
|
||||
| SessionAgentSelected
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
@@ -2325,9 +1928,10 @@ 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"
|
||||
@@ -4961,6 +4565,10 @@ 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
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
+58
-399
@@ -1,19 +1,15 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1",
|
||||
"id": "2d214a71-3b0a-48c1-a667-741952c4e188",
|
||||
"prevIds": [
|
||||
"db37a97f-9b5e-4c87-be8b-4feace35136c"
|
||||
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "workspace",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "data_migration",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "account_state",
|
||||
"entityType": "tables"
|
||||
@@ -66,14 +62,6 @@
|
||||
"name": "instruction_state",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "part",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "session_message",
|
||||
"entityType": "tables"
|
||||
@@ -83,11 +71,7 @@
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "session",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "session_share",
|
||||
"name": "session_v2",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
@@ -170,26 +154,6 @@
|
||||
"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,
|
||||
@@ -534,7 +498,7 @@
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "created",
|
||||
"entityType": "columns",
|
||||
@@ -960,116 +924,6 @@
|
||||
"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,
|
||||
@@ -1218,7 +1072,7 @@
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1228,7 +1082,7 @@
|
||||
"generated": null,
|
||||
"name": "project_id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1238,7 +1092,7 @@
|
||||
"generated": null,
|
||||
"name": "workspace_id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1248,7 +1102,7 @@
|
||||
"generated": null,
|
||||
"name": "parent_id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1258,7 +1112,7 @@
|
||||
"generated": null,
|
||||
"name": "fork_session_id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1268,7 +1122,7 @@
|
||||
"generated": null,
|
||||
"name": "fork_boundary",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1278,7 +1132,7 @@
|
||||
"generated": null,
|
||||
"name": "slug",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1288,7 +1142,7 @@
|
||||
"generated": null,
|
||||
"name": "directory",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1298,7 +1152,7 @@
|
||||
"generated": null,
|
||||
"name": "path",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1308,7 +1162,7 @@
|
||||
"generated": null,
|
||||
"name": "title",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1318,7 +1172,7 @@
|
||||
"generated": null,
|
||||
"name": "version",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1328,7 +1182,7 @@
|
||||
"generated": null,
|
||||
"name": "share_url",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1338,7 +1192,7 @@
|
||||
"generated": null,
|
||||
"name": "summary_additions",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1348,7 +1202,7 @@
|
||||
"generated": null,
|
||||
"name": "summary_deletions",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1358,7 +1212,7 @@
|
||||
"generated": null,
|
||||
"name": "summary_files",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1368,7 +1222,7 @@
|
||||
"generated": null,
|
||||
"name": "summary_diffs",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1378,7 +1232,7 @@
|
||||
"generated": null,
|
||||
"name": "metadata",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "real",
|
||||
@@ -1388,7 +1242,7 @@
|
||||
"generated": null,
|
||||
"name": "cost",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1398,7 +1252,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_input",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1408,7 +1262,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_output",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1418,7 +1272,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_reasoning",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1428,7 +1282,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_cache_read",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1438,7 +1292,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_cache_write",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1448,7 +1302,7 @@
|
||||
"generated": null,
|
||||
"name": "revert",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1458,7 +1312,7 @@
|
||||
"generated": null,
|
||||
"name": "permission",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1468,7 +1322,7 @@
|
||||
"generated": null,
|
||||
"name": "agent",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1478,7 +1332,7 @@
|
||||
"generated": null,
|
||||
"name": "model",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1488,7 +1342,7 @@
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1498,7 +1352,7 @@
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1508,7 +1362,7 @@
|
||||
"generated": null,
|
||||
"name": "time_compacting",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1518,7 +1372,7 @@
|
||||
"generated": null,
|
||||
"name": "time_archived",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1528,67 +1382,7 @@
|
||||
"generated": null,
|
||||
"name": "time_suspended",
|
||||
"entityType": "columns",
|
||||
"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"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -1669,14 +1463,14 @@
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_instruction_entry_session_id_session_id_fk",
|
||||
"name": "fk_instruction_entry_session_id_session_v2_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "instruction_entry"
|
||||
},
|
||||
@@ -1684,14 +1478,14 @@
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_instruction_state_session_id_session_id_fk",
|
||||
"name": "fk_instruction_state_session_id_session_v2_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "instruction_state"
|
||||
},
|
||||
@@ -1699,44 +1493,14 @@
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"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",
|
||||
"name": "fk_session_message_session_id_session_v2_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_message"
|
||||
},
|
||||
@@ -1744,14 +1508,14 @@
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_input_session_id_session_id_fk",
|
||||
"name": "fk_session_pending_session_id_session_v2_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_pending"
|
||||
},
|
||||
@@ -1766,24 +1530,9 @@
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_project_id_project_id_fk",
|
||||
"name": "fk_session_v2_project_id_project_id_fk",
|
||||
"entityType": "fks",
|
||||
"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"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -1824,15 +1573,6 @@
|
||||
"table": "workspace",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "data_migration_pk",
|
||||
"table": "data_migration",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
@@ -1923,24 +1663,6 @@
|
||||
"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"
|
||||
@@ -1955,7 +1677,7 @@
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_input_pk",
|
||||
"name": "session_pending_pk",
|
||||
"table": "session_pending",
|
||||
"entityType": "pks"
|
||||
},
|
||||
@@ -1964,17 +1686,8 @@
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_pk",
|
||||
"table": "session",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_share_pk",
|
||||
"table": "session_share",
|
||||
"name": "session_v2_pk",
|
||||
"table": "session_v2",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
@@ -2039,60 +1752,6 @@
|
||||
"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": [
|
||||
{
|
||||
@@ -2233,9 +1892,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_project_idx",
|
||||
"name": "session_v2_project_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -2247,9 +1906,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_workspace_idx",
|
||||
"name": "session_v2_workspace_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -2261,9 +1920,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_parent_idx",
|
||||
"name": "session_v2_parent_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -2273,11 +1932,11 @@
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"session\".\"time_suspended\" is not null",
|
||||
"where": "\"session_v2\".\"time_suspended\" is not null",
|
||||
"origin": "manual",
|
||||
"name": "session_time_suspended_idx",
|
||||
"name": "session_v2_time_suspended_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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)
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
@@ -100,9 +99,14 @@ async function drizzle(temporary: string, output: string, name?: string) {
|
||||
export default { ...config, out: ${JSON.stringify(output)} }
|
||||
`,
|
||||
)
|
||||
await $`bun drizzle-kit generate --config ${config} ${name ? ["--name", name] : []}`.cwd(
|
||||
path.join(root, "packages/core"),
|
||||
)
|
||||
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}.`)
|
||||
}
|
||||
|
||||
async function generatedMigrations(directory: string) {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
|
||||
export const DataMigrationTable = sqliteTable("data_migration", {
|
||||
name: text().primaryKey(),
|
||||
time_completed: integer().notNull(),
|
||||
})
|
||||
+1
-19
@@ -40,24 +40,6 @@ 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/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"),
|
||||
import("./migration/20260804233008_loose_psylocke"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -12,6 +12,7 @@ const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export type Migration = {
|
||||
id: string
|
||||
foreignKeys?: boolean
|
||||
up: (tx: Transaction) => Effect.Effect<void, unknown>
|
||||
}
|
||||
|
||||
@@ -21,8 +22,11 @@ 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")) return yield* applyOnly(db, migrations)
|
||||
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
|
||||
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)
|
||||
@@ -36,6 +40,10 @@ export function apply(db: Database) {
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* Effect.logInfo("database schema bootstrap completed", {
|
||||
migrations: migrations.length,
|
||||
durationMs: Date.now() - started,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -68,7 +76,9 @@ export function applyOnly(db: Database, input: Migration[]) {
|
||||
|
||||
for (const migration of input) {
|
||||
if (completed.has(migration.id)) continue
|
||||
yield* db.transaction((tx) =>
|
||||
const started = Date.now()
|
||||
yield* Effect.logInfo("database migration started", { migration: migration.id })
|
||||
const apply = db.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* migration.up(tx)
|
||||
yield* tx.run(
|
||||
@@ -76,6 +86,37 @@ 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,8 +10,6 @@ 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
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
@@ -1,17 +0,0 @@
|
||||
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
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
@@ -1,17 +0,0 @@
|
||||
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
|
||||
@@ -1,39 +0,0 @@
|
||||
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
|
||||
@@ -1,43 +0,0 @@
|
||||
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
|
||||
@@ -1,227 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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
|
||||
@@ -1,12 +0,0 @@
|
||||
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
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
@@ -1,35 +0,0 @@
|
||||
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
|
||||
@@ -1,86 +0,0 @@
|
||||
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
|
||||
@@ -1,18 +0,0 @@
|
||||
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
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
@@ -1,123 +0,0 @@
|
||||
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}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,138 @@
|
||||
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
|
||||
@@ -17,12 +17,6 @@ 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,
|
||||
@@ -81,7 +75,7 @@ export default {
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`aggregate_id\` text NOT NULL,
|
||||
\`seq\` integer NOT NULL,
|
||||
\`created\` integer NOT NULL,
|
||||
\`created\` integer DEFAULT 0 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
|
||||
@@ -148,7 +142,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_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
CONSTRAINT \`fk_instruction_entry_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
@@ -158,28 +152,7 @@ export default {
|
||||
\`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
|
||||
);
|
||||
`)
|
||||
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
|
||||
CONSTRAINT \`fk_instruction_state_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
@@ -191,7 +164,7 @@ export default {
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_session_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
CONSTRAINT \`fk_session_message_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
@@ -203,11 +176,11 @@ export default {
|
||||
\`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
|
||||
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 \`session\` (
|
||||
CREATE TABLE \`session_v2\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`project_id\` text NOT NULL,
|
||||
\`workspace_id\` text,
|
||||
@@ -240,18 +213,7 @@ export default {
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
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
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
|
||||
@@ -259,11 +221,6 @@ 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\`);`,
|
||||
)
|
||||
@@ -283,11 +240,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_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_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_time_suspended_idx\` ON \`session\` (\`time_suspended\`) WHERE "session"."time_suspended" is not null;`,
|
||||
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export const EventTable = sqliteTable(
|
||||
.notNull()
|
||||
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
|
||||
seq: integer().notNull(),
|
||||
created: integer().notNull(),
|
||||
created: integer().notNull().default(0),
|
||||
type: text().notNull(),
|
||||
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
|
||||
},
|
||||
|
||||
@@ -18,7 +18,6 @@ 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"
|
||||
@@ -221,14 +220,8 @@ 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
|
||||
@@ -364,45 +357,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 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,
|
||||
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)
|
||||
}
|
||||
: 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),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
// 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)
|
||||
@@ -557,8 +550,7 @@ 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)))
|
||||
@@ -738,23 +730,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("\\", "/")),
|
||||
})
|
||||
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 },
|
||||
)
|
||||
}),
|
||||
compact: Effect.fn("Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
@@ -835,9 +827,7 @@ 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)
|
||||
@@ -936,12 +926,7 @@ 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,6 +141,7 @@ 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) => {
|
||||
|
||||
@@ -7,23 +7,25 @@ import { Bus } from "../bus"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Model } from "../model"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionV1 } from "../v1/session"
|
||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionPending } from "./pending"
|
||||
import { Workspace } from "../workspace"
|
||||
import { InstructionState } from "./instruction-state"
|
||||
import { MessageTable, PartTable, SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import { Slug } from "../util/slug"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import type { SessionSchema } from "./schema"
|
||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
type MessageEvent = Exclude<
|
||||
CurrentDurableEvent,
|
||||
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type | typeof SessionEvent.InstructionsUpdated.Type
|
||||
| typeof SessionEvent.Created.Type
|
||||
| typeof SessionEvent.Forked.Type
|
||||
| typeof SessionEvent.Deleted.Type
|
||||
| typeof SessionEvent.InstructionsUpdated.Type
|
||||
>
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
@@ -50,73 +52,7 @@ const forkTitle = (value?: string) => {
|
||||
return `${value} (fork #1)`
|
||||
}
|
||||
|
||||
function usage(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"] | unknown): Usage | undefined {
|
||||
if (typeof part !== "object" || part === null) return undefined
|
||||
const value = part as Record<string, unknown>
|
||||
if (value.type !== "step-finish") return undefined
|
||||
if (!("cost" in value) || !("tokens" in value)) return undefined
|
||||
return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] }
|
||||
}
|
||||
|
||||
function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInsert {
|
||||
return {
|
||||
id: info.id,
|
||||
project_id: info.projectID,
|
||||
workspace_id: info.workspaceID ?? null,
|
||||
parent_id: info.parentID,
|
||||
slug: info.slug,
|
||||
directory: info.directory,
|
||||
path: info.path,
|
||||
title: info.title,
|
||||
agent: info.agent,
|
||||
model: info.model,
|
||||
version: info.version,
|
||||
share_url: info.share?.url,
|
||||
summary_additions: info.summary?.additions,
|
||||
summary_deletions: info.summary?.deletions,
|
||||
summary_files: info.summary?.files,
|
||||
summary_diffs: info.summary?.diffs ? [...info.summary.diffs] : undefined,
|
||||
metadata: info.metadata,
|
||||
cost: info.cost ?? 0,
|
||||
tokens_input: (info.tokens ?? { input: 0 }).input,
|
||||
tokens_output: (info.tokens ?? { output: 0 }).output,
|
||||
tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning,
|
||||
tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read,
|
||||
tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write,
|
||||
revert: info.revert
|
||||
? {
|
||||
messageID: SessionMessage.ID.make(info.revert.messageID),
|
||||
partID: info.revert.partID,
|
||||
snapshot: info.revert.snapshot,
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
: null,
|
||||
permission: info.permission ? [...info.permission] : undefined,
|
||||
time_created: info.time.created,
|
||||
time_updated: info.time.updated,
|
||||
time_compacting: info.time.compacting,
|
||||
time_archived: info.time.archived,
|
||||
}
|
||||
}
|
||||
|
||||
function messageData(
|
||||
info: (typeof SessionV1.Event.MessageUpdated.Type)["data"]["info"],
|
||||
): typeof MessageTable.$inferInsert.data {
|
||||
const { id: _, sessionID: __, ...rest } = info
|
||||
return rest as DeepMutable<typeof rest>
|
||||
}
|
||||
|
||||
function partData(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"]): typeof PartTable.$inferInsert.data {
|
||||
const { id: _, messageID: __, sessionID: ___, ...rest } = part
|
||||
return rest as DeepMutable<typeof rest>
|
||||
}
|
||||
|
||||
function applyUsage(
|
||||
db: DatabaseService,
|
||||
sessionID: (typeof SessionV1.Event.MessageUpdated.Type)["data"]["sessionID"],
|
||||
value: Usage,
|
||||
sign = 1,
|
||||
) {
|
||||
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage, sign = 1) {
|
||||
return db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
@@ -466,34 +402,39 @@ const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const db = (yield* Database.Service).db
|
||||
yield* bus.project(SessionV1.Event.Created, (event) =>
|
||||
yield* bus.project(SessionEvent.Created, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const stored = yield* db
|
||||
.insert(SessionTable)
|
||||
.values(sessionRow(event.data.info))
|
||||
.values({
|
||||
id: event.data.sessionID,
|
||||
project_id: event.data.projectID,
|
||||
workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
|
||||
parent_id: event.data.parentID,
|
||||
slug: event.data.slug,
|
||||
directory: event.data.location.directory,
|
||||
path: event.data.subpath,
|
||||
title: event.data.title,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
version: event.data.version,
|
||||
time_created: DateTime.toEpochMillis(event.created),
|
||||
time_updated: DateTime.toEpochMillis(event.created),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ sessionID: SessionTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
|
||||
if (event.data.info.workspaceID) {
|
||||
yield* db
|
||||
.update(WorkspaceTable)
|
||||
.set({ time_used: Date.now() })
|
||||
.where(eq(WorkspaceTable.id, event.data.info.workspaceID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
if (!event.data.location.workspaceID) return
|
||||
yield* db
|
||||
.update(WorkspaceTable)
|
||||
.set({ time_used: Date.now() })
|
||||
.where(eq(WorkspaceTable.id, event.data.location.workspaceID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionV1.Event.Updated, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set(sessionRow(event.data.info))
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Moved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* db
|
||||
@@ -511,81 +452,9 @@ const layer = Layer.effectDiscard(
|
||||
yield* InstructionState.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionV1.Event.Deleted, (event) =>
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Deleted, (event) =>
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionV1.Event.MessageUpdated, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const time_created = event.data.info.time.created
|
||||
const id = event.data.info.id
|
||||
const sessionID = event.data.info.sessionID
|
||||
const data = messageData(event.data.info)
|
||||
yield* db
|
||||
.insert(MessageTable)
|
||||
.values({ id, session_id: sessionID, time_created, data })
|
||||
.onConflictDoUpdate({ target: MessageTable.id, set: { data } })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionV1.Event.MessageRemoved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(PartTable)
|
||||
.where(and(eq(PartTable.message_id, event.data.messageID), eq(PartTable.session_id, event.data.sessionID)))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
for (const row of rows) {
|
||||
const previous = usage(row.data)
|
||||
if (previous) yield* applyUsage(db, event.data.sessionID, previous, -1)
|
||||
}
|
||||
yield* db
|
||||
.delete(MessageTable)
|
||||
.where(and(eq(MessageTable.id, event.data.messageID), eq(MessageTable.session_id, event.data.sessionID)))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionV1.Event.PartRemoved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(PartTable)
|
||||
.where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const previous = row && usage(row.data)
|
||||
if (previous) yield* applyUsage(db, event.data.sessionID, previous, -1)
|
||||
yield* db
|
||||
.delete(PartTable)
|
||||
.where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID)))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionV1.Event.PartUpdated, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const id = event.data.part.id
|
||||
const messageID = event.data.part.messageID
|
||||
const sessionID = event.data.part.sessionID
|
||||
const data = partData(event.data.part)
|
||||
const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(PartTable)
|
||||
.values({ id, message_id: messageID, session_id: sessionID, time_created: event.data.time, data })
|
||||
.onConflictDoUpdate({ target: PartTable.id, set: { data } })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const previous = row && usage(row.data)
|
||||
const next = usage(event.data.part)
|
||||
if (previous) yield* applyUsage(db, row.session_id, previous, -1)
|
||||
if (next) yield* applyUsage(db, sessionID, next)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.AgentSelected, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { PermissionV1 } from "../v1/permission"
|
||||
import { Project } from "../project"
|
||||
import type { SessionSchema } from "./schema"
|
||||
import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
||||
import { Workspace } from "../workspace"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { Instruction } from "@opencode-ai/schema/instruction"
|
||||
@@ -18,11 +17,9 @@ import type { RevertV1 } from "@opencode-ai/schema/session-revert"
|
||||
import type { Schema } from "effect"
|
||||
|
||||
type SessionMessageData = Omit<(typeof SessionMessage.Info)["Encoded"], "type" | "id">
|
||||
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
||||
type V1PartData = Omit<SessionV1.Part, "id" | "sessionID" | "messageID">
|
||||
|
||||
export const SessionTable = sqliteTable(
|
||||
"session",
|
||||
"session_v2",
|
||||
{
|
||||
id: text().$type<SessionSchema.ID>().primaryKey(),
|
||||
project_id: text()
|
||||
@@ -64,47 +61,15 @@ export const SessionTable = sqliteTable(
|
||||
time_suspended: integer(),
|
||||
},
|
||||
(table) => [
|
||||
index("session_project_idx").on(table.project_id),
|
||||
index("session_workspace_idx").on(table.workspace_id),
|
||||
index("session_parent_idx").on(table.parent_id),
|
||||
index("session_time_suspended_idx")
|
||||
index("session_v2_project_idx").on(table.project_id),
|
||||
index("session_v2_workspace_idx").on(table.workspace_id),
|
||||
index("session_v2_parent_idx").on(table.parent_id),
|
||||
index("session_v2_time_suspended_idx")
|
||||
.on(table.time_suspended)
|
||||
.where(sql`${table.time_suspended} is not null`),
|
||||
],
|
||||
)
|
||||
|
||||
export const MessageTable = sqliteTable(
|
||||
"message",
|
||||
{
|
||||
id: text().$type<MessageID>().primaryKey(),
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<V1MessageData>(),
|
||||
},
|
||||
(table) => [index("message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id)],
|
||||
)
|
||||
|
||||
export const PartTable = sqliteTable(
|
||||
"part",
|
||||
{
|
||||
id: text().$type<PartID>().primaryKey(),
|
||||
message_id: text()
|
||||
.$type<MessageID>()
|
||||
.notNull()
|
||||
.references(() => MessageTable.id, { onDelete: "cascade" }),
|
||||
session_id: text().$type<SessionSchema.ID>().notNull(),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<V1PartData>(),
|
||||
},
|
||||
(table) => [
|
||||
index("part_message_id_id_idx").on(table.message_id, table.id),
|
||||
index("part_session_idx").on(table.session_id),
|
||||
],
|
||||
)
|
||||
|
||||
export const SessionMessageTable = sqliteTable(
|
||||
"session_message",
|
||||
{
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { SessionTable } from "../session/sql"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
|
||||
export const SessionShareTable = sqliteTable("session_share", {
|
||||
session_id: text()
|
||||
.primaryKey()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
id: text().notNull(),
|
||||
secret: text().notNull(),
|
||||
url: text().notNull(),
|
||||
...Timestamps,
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
export * as SessionV1 from "./session"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export {
|
||||
AgentPart,
|
||||
AgentPartInput,
|
||||
Assistant,
|
||||
CompactionPart,
|
||||
Event,
|
||||
FilePart,
|
||||
FilePartInput,
|
||||
FilePartSource,
|
||||
FileSource,
|
||||
Format,
|
||||
Info,
|
||||
MessageID,
|
||||
OutputFormatJsonSchema,
|
||||
OutputFormatText,
|
||||
Part,
|
||||
PartID,
|
||||
PatchPart,
|
||||
Range,
|
||||
ReasoningPart,
|
||||
ResourceSource,
|
||||
RetryPart,
|
||||
SessionInfo,
|
||||
SnapshotPart,
|
||||
StepFinishPart,
|
||||
StepStartPart,
|
||||
SubtaskPart,
|
||||
SubtaskPartInput,
|
||||
SymbolSource,
|
||||
TextPart,
|
||||
TextPartInput,
|
||||
ToolPart,
|
||||
ToolState,
|
||||
ToolStateCompleted,
|
||||
ToolStateError,
|
||||
ToolStatePending,
|
||||
ToolStateRunning,
|
||||
User,
|
||||
WithParts,
|
||||
} from "@opencode-ai/schema/session-v1"
|
||||
|
||||
export const OutputLengthError = NamedError.create("MessageOutputLengthError", {})
|
||||
export const AuthError = NamedError.create("ProviderAuthError", { providerID: Schema.String, message: Schema.String })
|
||||
export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String })
|
||||
export const StructuredOutputError = NamedError.create("StructuredOutputError", {
|
||||
message: Schema.String,
|
||||
retries: NonNegativeInt,
|
||||
})
|
||||
export const APIError = NamedError.create("APIError", {
|
||||
message: Schema.String,
|
||||
statusCode: Schema.optional(NonNegativeInt),
|
||||
isRetryable: Schema.Boolean,
|
||||
responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
responseBody: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
export type APIError = Schema.Schema.Type<typeof APIError.Schema>
|
||||
export const ContextOverflowError = NamedError.create("ContextOverflowError", {
|
||||
message: Schema.String,
|
||||
responseBody: Schema.optional(Schema.String),
|
||||
})
|
||||
export const ContentFilterError = NamedError.create("ContentFilterError", { message: Schema.String })
|
||||
@@ -4,7 +4,6 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -89,10 +88,10 @@ const VersionedMessage = Bus.durable({
|
||||
},
|
||||
})
|
||||
|
||||
const DurableMessage = SessionV1.Event.MessageRemoved
|
||||
const DurableMessage = SessionEvent.Renamed
|
||||
const durableData = (sessionID: Session.ID, text: string) => ({
|
||||
sessionID,
|
||||
messageID: SessionV1.MessageID.ascending(`msg_${text}`),
|
||||
title: text,
|
||||
})
|
||||
|
||||
/** Followed log read without markers: the old `durable` stream shape. */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { SessionV1 as Wire } from "@opencode-ai/schema/session-v1"
|
||||
import { SessionV1 } from "../src/v1/session"
|
||||
|
||||
describe("legacy event schema compatibility", () => {
|
||||
test("Core references canonical SessionV1 definitions", () => {
|
||||
expect(SessionV1.Event.Created).toBe(Wire.Event.Created)
|
||||
expect(SessionV1.Event.PartUpdated).toBe(Wire.Event.PartUpdated)
|
||||
})
|
||||
|
||||
test("Core retains NamedError constructor identity", () => {
|
||||
const error = new SessionV1.APIError({ message: "failed", isRetryable: false })
|
||||
expect(error).toBeInstanceOf(SessionV1.APIError)
|
||||
expect(error.toObject()).toEqual({ name: "APIError", data: { message: "failed", isRetryable: false } })
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,6 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
@@ -87,7 +86,7 @@ describe("Session.create", () => {
|
||||
|
||||
expect(created.title).toBeUndefined()
|
||||
expect(row?.title).toBeNull()
|
||||
expect(event?.data).not.toHaveProperty("info.title")
|
||||
expect(event?.data).not.toHaveProperty("title")
|
||||
expect((yield* session.create({ location, title: "Explicit title" })).title).toBe("Explicit title")
|
||||
}),
|
||||
)
|
||||
@@ -456,32 +455,7 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the current Session projection after projected updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const input = { id, location }
|
||||
const created = yield* session.create(input)
|
||||
|
||||
yield* bus.publish(SessionV1.Event.Updated, {
|
||||
sessionID: id,
|
||||
info: SessionV1.SessionInfo.make({
|
||||
id,
|
||||
slug: "updated",
|
||||
version: "test",
|
||||
projectID: created.projectID,
|
||||
directory: created.location.directory,
|
||||
title: "updated",
|
||||
agent: "build",
|
||||
time: { created: 0, updated: 1 },
|
||||
}),
|
||||
})
|
||||
|
||||
expect(yield* session.create(input)).toMatchObject({ id, agent: "build" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists creation through the existing legacy created event", () =>
|
||||
it.effect("persists creation through the current created event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const { db } = yield* Database.Service
|
||||
@@ -489,7 +463,7 @@ describe("Session.create", () => {
|
||||
|
||||
expect(
|
||||
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
|
||||
).toMatchObject([{ type: Bus.versionedType(SessionV1.Event.Created.type, 1) }])
|
||||
).toMatchObject([{ type: Bus.versionedType(SessionEvent.Created.type, 1) }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -507,7 +481,7 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits legacy creation rows from the Session event stream", () =>
|
||||
it.effect("includes current creation rows in the Session event stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
@@ -521,8 +495,9 @@ describe("Session.create", () => {
|
||||
yield* SessionPending.promote(db, bus, created.id, "steer")
|
||||
|
||||
expect(
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(3), Stream.runCollect)),
|
||||
).toMatchObject([
|
||||
{ durable: { seq: 0 }, type: "session.created" },
|
||||
{
|
||||
durable: { seq: 1 },
|
||||
type: "session.input.admitted",
|
||||
@@ -605,7 +580,7 @@ describe("Session.create", () => {
|
||||
.all()
|
||||
.pipe(Effect.orDie)).map((event) => [event.seq, event.type]),
|
||||
).toEqual([
|
||||
[0, Bus.versionedType(SessionV1.Event.Created.type, 1)],
|
||||
[0, Bus.versionedType(SessionEvent.Created.type, 1)],
|
||||
[1, Bus.versionedType(SessionEvent.InputAdmitted.type, 1)],
|
||||
[2, Bus.versionedType(SessionEvent.InputPromoted.type, 1)],
|
||||
])
|
||||
@@ -618,7 +593,7 @@ describe("Session.create", () => {
|
||||
const session = yield* Session.Service
|
||||
const event = yield* Bus.Service
|
||||
const defect = new Error("unrelated projector defect")
|
||||
yield* event.project(SessionV1.Event.Created, () => Effect.die(defect))
|
||||
yield* event.project(SessionEvent.Created, () => Effect.die(defect))
|
||||
|
||||
expect(yield* session.create({ id, location }).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
}),
|
||||
@@ -672,7 +647,7 @@ describe("Session.create", () => {
|
||||
|
||||
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
||||
expect(
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
|
||||
}),
|
||||
)
|
||||
@@ -704,7 +679,9 @@ describe("Session.create", () => {
|
||||
yield* session.switchModel({ sessionID: created.id, model })
|
||||
|
||||
expect(yield* session.get(created.id)).toMatchObject({ model })
|
||||
const bus = Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect))
|
||||
const bus = Array.from(
|
||||
yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect),
|
||||
)
|
||||
expect(bus).toMatchObject([{ type: "session.model.selected" }])
|
||||
expect(bus[0]?.data).toEqual({ sessionID: created.id, model })
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("Session.move", () => {
|
||||
it.effect("moves a session whose source directory no longer exists", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const destination = AbsolutePath.make(tmp.path)
|
||||
const created = yield* session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "deleted")) }),
|
||||
})
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(destination)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
+271
-3193
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ import { CredentialGroup } from "./groups/credential.js"
|
||||
import { ProjectGroup } from "./groups/project.js"
|
||||
import { ProjectCopyGroup } from "./groups/project-copy.js"
|
||||
import { VcsGroup } from "./groups/vcs.js"
|
||||
import { MigrationGroup } from "./groups/migration.js"
|
||||
|
||||
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
|
||||
@@ -55,7 +56,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
|
||||
type SessionGroups<SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService> =
|
||||
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService>>
|
||||
| HttpApiGroup.AddMiddleware<typeof MessageGroup, SessionLocationId>
|
||||
| typeof MessageGroup
|
||||
|
||||
type FormGroups<
|
||||
LocationId extends HttpApiMiddleware.AnyId,
|
||||
@@ -85,6 +86,7 @@ type ApiGroups<
|
||||
| typeof HealthGroup
|
||||
| typeof ServerGroup
|
||||
| typeof DebugGroup
|
||||
| typeof MigrationGroup
|
||||
| LocationGroups<LocationId>
|
||||
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
|
||||
| SessionGroups<SessionLocationId, SessionLocationService>
|
||||
@@ -150,7 +152,7 @@ const makeApiFromGroup = <
|
||||
.add(AgentGroup.middleware(locationMiddleware))
|
||||
.add(PluginGroup.middleware(locationMiddleware))
|
||||
.add(makeSessionGroup(sessionLocationMiddleware))
|
||||
.add(MessageGroup.middleware(sessionLocationMiddleware))
|
||||
.add(MessageGroup)
|
||||
.add(ModelGroup.middleware(locationMiddleware))
|
||||
.add(GenerateGroup.middleware(locationMiddleware))
|
||||
.add(ProviderGroup.middleware(locationMiddleware))
|
||||
@@ -171,6 +173,7 @@ const makeApiFromGroup = <
|
||||
.add(ProjectCopyGroup.middleware(locationMiddleware))
|
||||
.add(VcsGroup.middleware(locationMiddleware))
|
||||
.add(DebugGroup)
|
||||
.add(MigrationGroup)
|
||||
.add(WebSearchGroup.middleware(locationMiddleware))
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -36,6 +36,7 @@ export const groupNames = {
|
||||
"server.health": "health",
|
||||
"server.server": "server",
|
||||
"server.debug": "debug",
|
||||
"server.migration": "migration",
|
||||
"server.location": "location",
|
||||
"server.agent": "agent",
|
||||
"server.plugin": "plugin",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
export const V1MigrationStatus = Schema.Struct({
|
||||
status: Schema.Literals(["required", "running", "completed"]),
|
||||
completed: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
total: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
})
|
||||
|
||||
export const V1MigrationResult = Schema.Struct({ status: Schema.Literal("completed") })
|
||||
|
||||
export const MigrationGroup = HttpApiGroup.make("server.migration")
|
||||
.add(
|
||||
HttpApiEndpoint.get("migration.v1.status", "/api/experimental/migration/v1", {
|
||||
success: V1MigrationStatus,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.experimental.migration.v1.status",
|
||||
summary: "Get V1 migration status",
|
||||
description: "Return the progress of the V1 to V2 session history migration.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("migration.v1.run", "/api/experimental/migration/v1", {
|
||||
success: V1MigrationResult,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.experimental.migration.v1.run",
|
||||
summary: "Run V1 migration",
|
||||
description: "Run or resume the V1 to V2 session history migration and wait for completion.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "migration" }))
|
||||
@@ -181,7 +181,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
success: Schema.Struct({ data: Session.Info }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.get",
|
||||
@@ -276,7 +275,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, InvalidRequestError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.move",
|
||||
@@ -470,7 +468,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
success: Schema.Struct({ data: Schema.Array(SessionMessage.Info) }),
|
||||
error: [SessionNotFoundError, UnknownError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.context",
|
||||
@@ -485,7 +482,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
success: Schema.Struct({ data: Schema.Array(SessionPending.Info) }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.pending.list",
|
||||
@@ -573,7 +569,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.log",
|
||||
@@ -620,7 +615,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
success: Schema.Struct({ data: SessionMessage.Info }),
|
||||
error: [SessionNotFoundError, MessageNotFoundError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.message",
|
||||
|
||||
@@ -2,11 +2,10 @@ export * as DurableEventManifest from "./durable-event-manifest.js"
|
||||
|
||||
import { Event } from "./event.js"
|
||||
import { SessionEvent } from "./session-event.js"
|
||||
import { SessionV1 } from "./session-v1.js"
|
||||
|
||||
export const SessionDurable = {
|
||||
definitions: Event.durableMap(SessionEvent.DurableDefinitions),
|
||||
schema: SessionEvent.Durable,
|
||||
} as const
|
||||
|
||||
export const Durable = Event.durableMap([...SessionV1.Event.Definitions, ...SessionEvent.DurableDefinitions])
|
||||
export const Durable = Event.durableMap(SessionEvent.DurableDefinitions)
|
||||
|
||||
@@ -29,21 +29,13 @@ import { Skill } from "./skill.js"
|
||||
import { SessionCompactionEvent } from "./session-compaction-event.js"
|
||||
import { SessionEvent } from "./session-event.js"
|
||||
import { SessionStatusEvent } from "./session-status-event.js"
|
||||
import { SessionV1 } from "./session-v1.js"
|
||||
import { TuiEvent } from "./tui-event.js"
|
||||
import { VcsEvent } from "./vcs-event.js"
|
||||
import { WorkspaceEvent } from "./workspace-event.js"
|
||||
import { WorktreeEvent } from "./worktree-event.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
|
||||
const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter(
|
||||
(definition) => definition.durability === "durable",
|
||||
)
|
||||
const sessionV1LiveDefinitions = SessionV1.Event.Definitions.filter(
|
||||
(definition) => definition.durability === "ephemeral",
|
||||
)
|
||||
|
||||
const coreDefinitions = Event.inventory(...sessionV1DurableDefinitions, ...SessionEvent.Definitions)
|
||||
const coreDefinitions = Event.inventory(...SessionEvent.Definitions)
|
||||
|
||||
const foundationDefinitions = Event.inventory(
|
||||
...ModelsDev.Event.Definitions,
|
||||
@@ -79,8 +71,6 @@ export const ServerDefinitions = Event.inventory(
|
||||
...VcsEvent.Definitions,
|
||||
McpEvent.StatusChanged,
|
||||
McpEvent.ResourcesChanged,
|
||||
// Shared transitional event retained until the TUI moves to the current session error surface.
|
||||
SessionV1.Error,
|
||||
)
|
||||
export const Server = Event.latest(ServerDefinitions)
|
||||
export type ServerEvent = Schema.Schema.Type<(typeof ServerDefinitions)[number]>
|
||||
@@ -88,7 +78,6 @@ export const isServer = (event: { readonly type: string }): event is ServerEvent
|
||||
|
||||
export const Definitions = Event.inventory(
|
||||
...foundationDefinitions,
|
||||
...sessionV1LiveDefinitions,
|
||||
...InstallationEvent.Definitions,
|
||||
...featureDefinitions,
|
||||
...LspEvent.Definitions,
|
||||
|
||||
@@ -45,6 +45,24 @@ const options = {
|
||||
version: 1,
|
||||
},
|
||||
} as const
|
||||
export const Created = Event.durable({
|
||||
type: "session.created",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
projectID: Project.ID,
|
||||
location: Location.Ref,
|
||||
subpath: RelativePath.pipe(optional),
|
||||
parentID: SessionID.pipe(optional),
|
||||
slug: Schema.String,
|
||||
title: Schema.String.pipe(optional),
|
||||
agent: Agent.ID.pipe(optional),
|
||||
model: Model.Ref.pipe(optional),
|
||||
version: Schema.String,
|
||||
},
|
||||
})
|
||||
export type Created = typeof Created.Type
|
||||
|
||||
export const AgentSelected = Event.durable({
|
||||
type: "session.agent.selected",
|
||||
...options,
|
||||
@@ -547,6 +565,7 @@ export namespace RevertEvent {
|
||||
}
|
||||
|
||||
export const Definitions = Event.inventory(
|
||||
Created,
|
||||
AgentSelected,
|
||||
ModelSelected,
|
||||
Moved,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user