From 0fbe489e357c5463bf2f5ef1b6cfcb619bab88cf Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 5 Aug 2026 18:57:58 -0700 Subject: [PATCH] feat(core): migrate v1 data to v2 (#40723) --- docs/design/v1-v2-database-migration.md | 232 +- packages/cli/script/build.ts | 2 +- packages/cli/src/acp/event.ts | 4 +- packages/cli/src/server-process.ts | 9 +- packages/cli/test/acp/event-behavior.test.ts | 10 +- .../cli/test/acp/permission-behavior.test.ts | 16 +- packages/client/src/effect/api/api.ts | 50 +- .../client/src/effect/generated/client.ts | 29 +- .../client/src/promise/generated/client.ts | 26 +- .../client/src/promise/generated/types.ts | 449 +-- packages/core/package.json | 1 + packages/core/schema.json | 457 +-- packages/core/script/benchmark-location.ts | 70 + packages/core/script/migration.ts | 12 +- packages/core/src/data-migration.sql.ts | 6 - packages/core/src/database/migration.gen.ts | 21 +- packages/core/src/database/migration.ts | 45 +- .../20260622170816_reset_v2_session_state.ts | 2 - ...0260702134641_add_session_context_entry.ts | 21 - ...60703090000_reset_v2_event_rename_sweep.ts | 17 - .../20260703181610_event_created_column.ts | 11 - ...703190000_reset_v2_shell_event_payloads.ts | 14 - .../20260703200000_reset_v2_session_events.ts | 14 - .../20260705180000_rename_instructions.ts | 17 - .../20260706223930_add-session-fork.ts | 39 - .../20260707010146_durable_session_inbox.ts | 43 - ...260707120000_migrate_prelaunch_v2_state.ts | 227 -- .../20260709013000_generic_session_input.ts | 77 - .../migration/20260709025533_drop-todo.ts | 12 - .../20260709163752_time_suspended.ts | 14 - .../20260709190621_session_pending_table.ts | 35 - .../20260710025429_instruction_sync.ts | 86 - .../database/migration/20260716020354_kv.ts | 18 - ...60722011141_delete_tool_progress_events.ts | 11 - .../20260722170000_canonical_tool_results.ts | 123 - .../20260729022634_session_fork_boundary.ts | 13 - .../20260730195856_optional_session_title.ts | 14 - .../20260804233008_loose_psylocke.ts | 138 + ...0260805200742_import_legacy_credentials.ts | 102 + packages/core/src/database/path.ts | 3 +- packages/core/src/database/schema.gen.ts | 65 +- packages/core/src/database/v1-migration.ts | 970 +++++ packages/core/src/event/sql.ts | 2 +- packages/core/src/session.ts | 123 +- packages/core/src/session/message-updater.ts | 1 + packages/core/src/session/projector.ts | 188 +- packages/core/src/session/sql.ts | 45 +- packages/core/src/share/sql.ts | 13 - packages/core/src/v1/session.ts | 68 - packages/core/test/bus.test.ts | 5 +- packages/core/test/database-migration.test.ts | 1564 +------- .../core/test/legacy-event-schema.test.ts | 16 - packages/core/test/session-create.test.ts | 47 +- packages/core/test/session-move.test.ts | 57 + packages/core/test/v1-migration.test.ts | 1207 ++++++ packages/protocol/openapi.json | 3464 ++--------------- packages/protocol/src/api.ts | 7 +- packages/protocol/src/client.ts | 1 + packages/protocol/src/groups/migration.ts | 28 + packages/protocol/src/groups/session.ts | 6 - packages/schema/src/durable-event-manifest.ts | 3 +- packages/schema/src/event-manifest.ts | 13 +- packages/schema/src/session-event.ts | 19 + packages/schema/test/event-manifest.test.ts | 26 +- packages/server/src/handlers.ts | 2 + packages/server/src/handlers/migration.ts | 13 + packages/server/src/handlers/session.ts | 32 +- packages/server/src/process.ts | 10 +- packages/server/src/routes.ts | 8 +- packages/tui/src/app.tsx | 31 +- .../tui/src/component/migration-overlay.tsx | 72 + packages/tui/src/context/session-tabs.tsx | 7 +- .../feature-plugins/system/notifications.ts | 20 - packages/tui/src/routes/session/index.tsx | 12 +- .../test/cli/cmd/tui/notifications.test.ts | 35 - packages/www/openapi.json | 3464 ++--------------- packages/www/public/openapi.json | 3464 ++--------------- 77 files changed, 4268 insertions(+), 13330 deletions(-) create mode 100644 packages/core/script/benchmark-location.ts delete mode 100644 packages/core/src/data-migration.sql.ts delete mode 100644 packages/core/src/database/migration/20260702134641_add_session_context_entry.ts delete mode 100644 packages/core/src/database/migration/20260703090000_reset_v2_event_rename_sweep.ts delete mode 100644 packages/core/src/database/migration/20260703181610_event_created_column.ts delete mode 100644 packages/core/src/database/migration/20260703190000_reset_v2_shell_event_payloads.ts delete mode 100644 packages/core/src/database/migration/20260703200000_reset_v2_session_events.ts delete mode 100644 packages/core/src/database/migration/20260705180000_rename_instructions.ts delete mode 100644 packages/core/src/database/migration/20260706223930_add-session-fork.ts delete mode 100644 packages/core/src/database/migration/20260707010146_durable_session_inbox.ts delete mode 100644 packages/core/src/database/migration/20260707120000_migrate_prelaunch_v2_state.ts delete mode 100644 packages/core/src/database/migration/20260709013000_generic_session_input.ts delete mode 100644 packages/core/src/database/migration/20260709025533_drop-todo.ts delete mode 100644 packages/core/src/database/migration/20260709163752_time_suspended.ts delete mode 100644 packages/core/src/database/migration/20260709190621_session_pending_table.ts delete mode 100644 packages/core/src/database/migration/20260710025429_instruction_sync.ts delete mode 100644 packages/core/src/database/migration/20260716020354_kv.ts delete mode 100644 packages/core/src/database/migration/20260722011141_delete_tool_progress_events.ts delete mode 100644 packages/core/src/database/migration/20260722170000_canonical_tool_results.ts delete mode 100644 packages/core/src/database/migration/20260729022634_session_fork_boundary.ts delete mode 100644 packages/core/src/database/migration/20260730195856_optional_session_title.ts create mode 100644 packages/core/src/database/migration/20260804233008_loose_psylocke.ts create mode 100644 packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts create mode 100644 packages/core/src/database/v1-migration.ts delete mode 100644 packages/core/src/share/sql.ts delete mode 100644 packages/core/src/v1/session.ts delete mode 100644 packages/core/test/legacy-event-schema.test.ts create mode 100644 packages/core/test/session-move.test.ts create mode 100644 packages/core/test/v1-migration.test.ts create mode 100644 packages/protocol/src/groups/migration.ts create mode 100644 packages/server/src/handlers/migration.ts create mode 100644 packages/tui/src/component/migration-overlay.tsx diff --git a/docs/design/v1-v2-database-migration.md b/docs/design/v1-v2-database-migration.md index d03329da300..f3d5b4e4adc 100644 --- a/docs/design/v1-v2-database-migration.md +++ b/docs/design/v1-v2-database-migration.md @@ -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: ()]` 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,36 @@ 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: +Before transforming V1 rows, look for `opencode-next.db` in the data directory. This file was used by pre-launch V2 +builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the +current `project`, `session_v2`, and `session_message` tables. Existing current projects and Sessions win ID collisions. +Do not copy its durable events or runtime caches; initialize each imported Session's `event_sequence` watermark from its +maximum message sequence. Commit each imported Session independently and leave the source database untouched. -- 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. +The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor: +the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed. + +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. + +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. + +Ensure the global project exists using the current platform's filesystem root as its worktree. Process every `session` +row, including archived, root, child, and empty sessions, as well as sessions whose messages are all skipped or internal. +Reassign beta and V1 Sessions whose referenced project row is missing to the global project and log a warning. Each +successfully committed session advances the cursor. + +## Testing + +Detailed migration test design is deferred until after the canonical migration is implemented. diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index ad71d93854a..67aa029e5cf 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -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", })) }, diff --git a/packages/cli/src/acp/event.ts b/packages/cli/src/acp/event.ts index 7d6e2b586f4..5d33111ec5c 100644 --- a/packages/cli/src/acp/event.ts +++ b/packages/cli/src/acp/event.ts @@ -127,7 +127,7 @@ export async function streamTurn(input: { if (next.done) throw new Error("event stream disconnected during prompt execution") const event = next.value if (event.type === "session.created") { - const parentID = event.data.info.parentID + const parentID = event.data.parentID if (!parentID) continue const parent = parentID === input.sessionID ? undefined : children.get(parentID) if ((mode === "turn" && parentID === input.sessionID) || parent) { @@ -135,7 +135,7 @@ export async function streamTurn(input: { id: event.data.sessionID, parentID, depth: parent ? parent.depth + 1 : 1, - title: event.data.info.title, + title: event.data.title, } children.set(child.id, child) openChildren.add(child.id) diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 273b11a0da6..aa4e8733201 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -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( diff --git a/packages/cli/test/acp/event-behavior.test.ts b/packages/cli/test/acp/event-behavior.test.ts index 71ea8382591..44a89ec899d 100644 --- a/packages/cli/test/acp/event-behavior.test.ts +++ b/packages/cli/test/acp/event-behavior.test.ts @@ -199,7 +199,7 @@ describe("acp event behavior", () => { send( durableEvent("session.created", { sessionID: "ses_child", - info: childSession("ses_child", "ses_parent", "Explore code"), + ...childSession("ses_child", "ses_parent", "Explore code"), }), ) send(durableEvent("session.execution.started", { sessionID: "ses_child" })) @@ -280,7 +280,7 @@ describe("acp event behavior", () => { send( durableEvent("session.created", { sessionID: "ses_background", - info: childSession("ses_background", "ses_parent", "Background research"), + ...childSession("ses_background", "ses_parent", "Background research"), }), ) send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" })) @@ -303,7 +303,7 @@ describe("acp event behavior", () => { fixture.send( durableEvent("session.created", { sessionID: "ses_future", - info: childSession("ses_future", "ses_parent", "Later turn child"), + ...childSession("ses_future", "ses_parent", "Later turn child"), }), ) fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" })) @@ -749,14 +749,12 @@ function turn(input: { function childSession(id: string, parentID: string, title: string) { return { - id, slug: id, projectID: "project", - directory: "/workspace", + location: { directory: "/workspace" }, parentID, title, version: "test", - time: { created: 1, updated: 1 }, } } diff --git a/packages/cli/test/acp/permission-behavior.test.ts b/packages/cli/test/acp/permission-behavior.test.ts index a0044f1ff9d..3dabbe57144 100644 --- a/packages/cli/test/acp/permission-behavior.test.ts +++ b/packages/cli/test/acp/permission-behavior.test.ts @@ -161,16 +161,12 @@ describe("acp permission behavior", () => { send( durableEvent("session.created", { sessionID: "ses_child", - info: { - id: "ses_child", - slug: "ses_child", - projectID: "project", - directory: "/workspace", - parentID: "ses_parent", - title: "Review code", - version: "test", - time: { created: 1, updated: 1 }, - }, + slug: "ses_child", + projectID: "project", + location: { directory: "/workspace" }, + parentID: "ses_parent", + title: "Review code", + version: "test", }), ) send(durableEvent("session.execution.started", { sessionID: "ses_child" })) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 3a65e5812ca..d45c50e8171 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -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 @@ -1539,19 +1559,36 @@ export interface DebugApi { readonly location: { readonly list: DebugLocationListOperation; readonly evict: DebugLocationEvictOperation } } -export type Endpoint27_0Input = { +export type Endpoint27_0Output = + | { readonly status: "required" | "completed" } + | { + readonly status: "running" + readonly progress: { + readonly label: string + readonly numerator?: number | undefined + readonly denominator?: number | undefined + } + } + | { readonly status: "error"; readonly error: string } +export type MigrationV1StatusOperation = () => Effect.Effect + +export interface MigrationApi { + readonly v1: { readonly status: MigrationV1StatusOperation } +} + +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 } -export type WebsearchProvidersOperation = (input?: Endpoint27_0Input) => Effect.Effect +export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray } +export type WebsearchProvidersOperation = (input?: Endpoint28_0Input) => Effect.Effect -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 = (input: Endpoint27_1Input) => Effect.Effect +export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response } +export type WebsearchQueryOperation = (input: Endpoint28_1Input) => Effect.Effect export interface WebsearchApi { readonly providers: WebsearchProvidersOperation @@ -1586,5 +1623,6 @@ export interface AppApi { readonly projectCopy: ProjectCopyApi readonly vcs: VcsApi readonly debug: DebugApi + readonly migration: MigrationApi readonly websearch: WebsearchApi } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 5f975ee3b32..dc2071a54d6 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -215,10 +215,11 @@ 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 +1218,27 @@ 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()( +const Endpoint27_0 = (raw: RawClient["server.migration"]) => () => + preserveEffect()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError))) + +const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } }) + +const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) => + preserveEffect()( raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) => - preserveEffect()( +const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) => + preserveEffect()( 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 +1269,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 }) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 4e48ef6450c..42d0440fc01 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -211,6 +211,7 @@ import type { DebugLocationListOutput, DebugLocationEvictInput, DebugLocationEvictOutput, + MigrationV1StatusOutput, WebsearchProvidersInput, WebsearchProvidersOutput, WebsearchQueryInput, @@ -492,7 +493,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 +719,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 +731,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 +794,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 +827,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 +1769,21 @@ export function make(options: ClientOptions) { ), }, }, + migration: { + v1: { + status: (requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/experimental/migration/v1`, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + }, websearch: { providers: (input?: WebsearchProvidersInput, requestOptions?: RequestOptions) => request( diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index bb1da0aa2fe..010fb2dc218 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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 -} - -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 - -export type SessionV1UserMessage = { - id: string - sessionID: string - role: "user" - time: { created: number } - format?: SessionV1OutputFormat | undefined - summary?: { title?: string | undefined; body?: string | undefined; diffs: Array } | 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] -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 } - 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] 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 | 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,11 @@ export type DebugLocationEvictInput = { export type DebugLocationEvictOutput = void +export type MigrationV1StatusOutput = + | { status: "required" | "completed" } + | { status: "running"; progress: { label: string; numerator?: number | undefined; denominator?: number | undefined } } + | { status: "error"; error: string } + export type WebsearchProvidersInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/core/package.json b/packages/core/package.json index d417d9dfa84..240c6667d56 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" }, diff --git a/packages/core/schema.json b/packages/core/schema.json index 34e864a690d..fe843eb9272 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -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": [] diff --git a/packages/core/script/benchmark-location.ts b/packages/core/script/benchmark-location.ts new file mode 100644 index 00000000000..d29fed63b55 --- /dev/null +++ b/packages/core/script/benchmark-location.ts @@ -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 = (effect: Effect.Effect) => + Effect.gen(function* () { + const start = performance.now() + yield* effect + return performance.now() - start + }) + +const stats = (samples: ReadonlyArray) => { + 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) => { + 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) diff --git a/packages/core/script/migration.ts b/packages/core/script/migration.ts index 4f383f5f8f6..e709111ab4d 100644 --- a/packages/core/script/migration.ts +++ b/packages/core/script/migration.ts @@ -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) { diff --git a/packages/core/src/data-migration.sql.ts b/packages/core/src/data-migration.sql.ts deleted file mode 100644 index ba446b501ce..00000000000 --- a/packages/core/src/data-migration.sql.ts +++ /dev/null @@ -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(), -}) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 68082bf9828..a247e95b29a 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -40,24 +40,7 @@ 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"), + import("./migration/20260805200742_import_legacy_credentials"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts index 17bf0a5c44e..4dd79f7f353 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -12,6 +12,7 @@ const lock = Semaphore.makeUnsafe(1) export type Migration = { id: string + foreignKeys?: boolean up: (tx: Transaction) => Effect.Effect } @@ -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, + }) } }) } diff --git a/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts b/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts index b771a64bb74..9b4bb8fe504 100644 --- a/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts +++ b/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts @@ -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 diff --git a/packages/core/src/database/migration/20260702134641_add_session_context_entry.ts b/packages/core/src/database/migration/20260702134641_add_session_context_entry.ts deleted file mode 100644 index 13b37d7feff..00000000000 --- a/packages/core/src/database/migration/20260702134641_add_session_context_entry.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260703090000_reset_v2_event_rename_sweep.ts b/packages/core/src/database/migration/20260703090000_reset_v2_event_rename_sweep.ts deleted file mode 100644 index 20672f07340..00000000000 --- a/packages/core/src/database/migration/20260703090000_reset_v2_event_rename_sweep.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260703181610_event_created_column.ts b/packages/core/src/database/migration/20260703181610_event_created_column.ts deleted file mode 100644 index 29d2cf9c12d..00000000000 --- a/packages/core/src/database/migration/20260703181610_event_created_column.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260703190000_reset_v2_shell_event_payloads.ts b/packages/core/src/database/migration/20260703190000_reset_v2_shell_event_payloads.ts deleted file mode 100644 index ffbe40652c5..00000000000 --- a/packages/core/src/database/migration/20260703190000_reset_v2_shell_event_payloads.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260703200000_reset_v2_session_events.ts b/packages/core/src/database/migration/20260703200000_reset_v2_session_events.ts deleted file mode 100644 index 75108b3f118..00000000000 --- a/packages/core/src/database/migration/20260703200000_reset_v2_session_events.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260705180000_rename_instructions.ts b/packages/core/src/database/migration/20260705180000_rename_instructions.ts deleted file mode 100644 index 93cb71330e4..00000000000 --- a/packages/core/src/database/migration/20260705180000_rename_instructions.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260706223930_add-session-fork.ts b/packages/core/src/database/migration/20260706223930_add-session-fork.ts deleted file mode 100644 index 2d7b09b34a6..00000000000 --- a/packages/core/src/database/migration/20260706223930_add-session-fork.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260707010146_durable_session_inbox.ts b/packages/core/src/database/migration/20260707010146_durable_session_inbox.ts deleted file mode 100644 index e4909926208..00000000000 --- a/packages/core/src/database/migration/20260707010146_durable_session_inbox.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260707120000_migrate_prelaunch_v2_state.ts b/packages/core/src/database/migration/20260707120000_migrate_prelaunch_v2_state.ts deleted file mode 100644 index e921d409047..00000000000 --- a/packages/core/src/database/migration/20260707120000_migrate_prelaunch_v2_state.ts +++ /dev/null @@ -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() - 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) { - 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, 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 { - return isObject(value) ? value : {} -} - -function defined(value: Record) { - return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined)) -} diff --git a/packages/core/src/database/migration/20260709013000_generic_session_input.ts b/packages/core/src/database/migration/20260709013000_generic_session_input.ts deleted file mode 100644 index 660830bf1eb..00000000000 --- a/packages/core/src/database/migration/20260709013000_generic_session_input.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260709025533_drop-todo.ts b/packages/core/src/database/migration/20260709025533_drop-todo.ts deleted file mode 100644 index 1557f889b1c..00000000000 --- a/packages/core/src/database/migration/20260709025533_drop-todo.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260709163752_time_suspended.ts b/packages/core/src/database/migration/20260709163752_time_suspended.ts deleted file mode 100644 index 70c8f136a88..00000000000 --- a/packages/core/src/database/migration/20260709163752_time_suspended.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260709190621_session_pending_table.ts b/packages/core/src/database/migration/20260709190621_session_pending_table.ts deleted file mode 100644 index 1fc97a2257a..00000000000 --- a/packages/core/src/database/migration/20260709190621_session_pending_table.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260710025429_instruction_sync.ts b/packages/core/src/database/migration/20260710025429_instruction_sync.ts deleted file mode 100644 index 6239fd9bcef..00000000000 --- a/packages/core/src/database/migration/20260710025429_instruction_sync.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260716020354_kv.ts b/packages/core/src/database/migration/20260716020354_kv.ts deleted file mode 100644 index 5a0ac8bdd51..00000000000 --- a/packages/core/src/database/migration/20260716020354_kv.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260722011141_delete_tool_progress_events.ts b/packages/core/src/database/migration/20260722011141_delete_tool_progress_events.ts deleted file mode 100644 index afb8f443347..00000000000 --- a/packages/core/src/database/migration/20260722011141_delete_tool_progress_events.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260722170000_canonical_tool_results.ts b/packages/core/src/database/migration/20260722170000_canonical_tool_results.ts deleted file mode 100644 index 2cb656cd026..00000000000 --- a/packages/core/src/database/migration/20260722170000_canonical_tool_results.ts +++ /dev/null @@ -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 => (isObject(value) ? value : {}) - -const stringify = (value: unknown) => { - try { - return JSON.stringify(value, null, 2) ?? String(value) - } catch { - return String(value) - } -} - -const contentOf = (state: Record) => (Array.isArray(state.content) ? state.content : []) -const resultOf = (state: Record) => - isObject(state.result) && "value" in state.result ? state.result.value : state.result -const metadataOf = (state: Record) => { - 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) => { - 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[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}`) - } - }) -} diff --git a/packages/core/src/database/migration/20260729022634_session_fork_boundary.ts b/packages/core/src/database/migration/20260729022634_session_fork_boundary.ts deleted file mode 100644 index bf574b4807c..00000000000 --- a/packages/core/src/database/migration/20260729022634_session_fork_boundary.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260730195856_optional_session_title.ts b/packages/core/src/database/migration/20260730195856_optional_session_title.ts deleted file mode 100644 index a8027ca4f23..00000000000 --- a/packages/core/src/database/migration/20260730195856_optional_session_title.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/migration/20260804233008_loose_psylocke.ts b/packages/core/src/database/migration/20260804233008_loose_psylocke.ts new file mode 100644 index 00000000000..9d4bf064087 --- /dev/null +++ b/packages/core/src/database/migration/20260804233008_loose_psylocke.ts @@ -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 diff --git a/packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts b/packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts new file mode 100644 index 00000000000..38d95517e68 --- /dev/null +++ b/packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts @@ -0,0 +1,102 @@ +import path from "node:path" +import { sql } from "drizzle-orm" +import { Effect, Option, Schema } from "effect" +import { Credential } from "@opencode-ai/schema/credential" +import { Integration } from "@opencode-ai/schema/integration" +import { NonNegativeInt } from "@opencode-ai/schema/schema" +import { Global } from "@opencode-ai/util/global" +import type { DatabaseMigration } from "../migration" + +const LegacyOAuth = Schema.Struct({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, + accountId: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String), +}) +const LegacyKey = Schema.Struct({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) +const LegacyWellKnown = Schema.Struct({ + type: Schema.Literal("wellknown"), + key: Schema.String, + token: Schema.String, +}) +const LegacyValue = Schema.Union([LegacyOAuth, LegacyKey, LegacyWellKnown]) +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const decodeValue = Schema.decodeUnknownOption(LegacyValue) +const wellKnownSourcesKey = "wellknown:sources" + +export default { + id: "20260805200742_import_legacy_credentials", + up(tx) { + return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json")) + }, +} satisfies DatabaseMigration.Migration + +export function importLegacyCredentials(tx: Parameters[0], filepath: string) { + return Effect.gen(function* () { + const file = Bun.file(filepath) + if (!(yield* Effect.promise(() => file.exists()))) return + const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text()))) + if (typeof input !== "object" || input === null || Array.isArray(input)) { + return yield* Effect.fail(new Error("Legacy credential file must contain an object")) + } + + const origins: string[] = [] + for (const [id, raw] of Object.entries(input)) { + const value = Option.getOrUndefined(decodeValue(raw)) + if (!value) continue + const integrationID = id.replace(/\/+$/, "") + if (!integrationID) continue + if (value.type === "wellknown") origins.push(integrationID) + if (yield* tx.get(sql`SELECT id FROM credential WHERE integration_id = ${integrationID}`)) continue + + const credential = + value.type === "api" + ? Credential.Key.make({ type: "key", key: value.key, metadata: value.metadata }) + : value.type === "wellknown" + ? Credential.Key.make({ type: "key", key: value.token }) + : Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make(methodID(integrationID)), + refresh: value.refresh, + access: value.access, + expires: value.expires, + metadata: + value.accountId || value.enterpriseUrl + ? { + ...(value.accountId ? { accountID: value.accountId } : {}), + ...(value.enterpriseUrl ? { enterpriseUrl: value.enterpriseUrl } : {}), + } + : undefined, + }) + const now = Date.now() + yield* tx.run(sql` + INSERT INTO credential (id, integration_id, label, value, time_created, time_updated) + VALUES (${Credential.ID.create()}, ${integrationID}, 'default', ${JSON.stringify(credential)}, ${now}, ${now}) + `) + } + + if (!origins.length) return + const stored = yield* tx.get<{ value: string }>(sql`SELECT value FROM kv WHERE key = ${wellKnownSourcesKey}`) + const decoded = stored ? Option.getOrUndefined(decodeJson(stored.value)) : undefined + const current = Array.isArray(decoded) ? decoded.filter((item): item is string => typeof item === "string") : [] + const value = JSON.stringify(Array.from(new Set([...current, ...origins]))) + const now = Date.now() + yield* tx.run(sql` + INSERT INTO kv (key, value, time_created, time_updated) + VALUES (${wellKnownSourcesKey}, ${value}, ${now}, ${now}) + ON CONFLICT (key) DO UPDATE SET value = excluded.value, time_updated = excluded.time_updated + `) + }) +} + +function methodID(integrationID: string) { + if (integrationID === "openai") return "chatgpt-browser" + if (["github-copilot", "opencode", "xai"].includes(integrationID)) return "device" + return "oauth" +} diff --git a/packages/core/src/database/path.ts b/packages/core/src/database/path.ts index 93fd2d5773b..6bb552b0c5a 100644 --- a/packages/core/src/database/path.ts +++ b/packages/core/src/database/path.ts @@ -14,7 +14,8 @@ function isWindowsStoragePath(input: string) { function absolute(input: string) { const result = storagePath(input) - if (!nodePath.posix.isAbsolute(result) && !(process.platform === "win32" && isWindowsStoragePath(result))) { + // Persisted projects and sessions can move between operating systems during migration. + if (!nodePath.posix.isAbsolute(result) && !isWindowsStoragePath(result)) { throw new Error(`Path is not absolute: ${input}`) } return result diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 7aa38f5f62c..8625d63e51a 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -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;`, ) }) }, diff --git a/packages/core/src/database/v1-migration.ts b/packages/core/src/database/v1-migration.ts new file mode 100644 index 00000000000..b464a5d01cf --- /dev/null +++ b/packages/core/src/database/v1-migration.ts @@ -0,0 +1,970 @@ +export * as V1Migration from "./v1-migration" + +import { Cause, Effect, Layer, 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" +import { Global } from "@opencode-ai/util/global" +import { existsSync } from "node:fs" +import path from "node:path" +import type { Database as SQLiteDatabase } from "bun:sqlite" +import { Project } from "@opencode-ai/schema/project" + +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 + readonly parts: ReadonlyArray +} + +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 + }> + 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 +} + +type Progress = { + readonly label: string + readonly numerator?: number + readonly denominator?: number +} + +export type Status = + | { readonly status: "required" | "completed" } + | { readonly status: "running"; readonly progress: Progress } + | { readonly status: "error"; readonly error: string } + +type RunResult = { + readonly status: "completed" +} + +type Options = { + readonly nextDatabasePath?: string +} + +type MigrationState = + | { readonly phase: "sessions"; readonly cursor?: string } + | { readonly phase: "completed" } + +type RuntimeState = + | { readonly status: "idle" } + | { readonly status: "running"; readonly progress: Progress } + | { readonly status: "error"; readonly error: string } + +type NextProject = { + readonly id: string + readonly worktree: string + readonly vcs: string | null + readonly name: string | null + readonly icon_url: string | null + readonly icon_url_override: string | null + readonly icon_color: string | null + readonly time_created: number + readonly time_updated: number + readonly time_initialized: number | null + readonly sandboxes: string + readonly commands: string | null +} + +type NextSession = { + readonly id: string + readonly project_id: string + readonly workspace_id: string | null + readonly parent_id: string | null + readonly fork_session_id: string | null + readonly fork_boundary: string | null + readonly slug: string + readonly directory: string + readonly path: string | null + readonly title: string | null + readonly version: string + readonly share_url: string | null + readonly summary_additions: number | null + readonly summary_deletions: number | null + readonly summary_files: number | null + readonly summary_diffs: string | null + readonly metadata: string | null + readonly cost: number + readonly tokens_input: number + readonly tokens_output: number + readonly tokens_reasoning: number + readonly tokens_cache_read: number + readonly tokens_cache_write: number + readonly revert: string | null + readonly permission: string | null + readonly agent: string | null + readonly model: string | null + readonly time_created: number + readonly time_updated: number + readonly time_compacting: number | null + readonly time_archived: number | null + readonly time_suspended: number | null +} + +type NextMessage = { + readonly id: string + readonly session_id: string + readonly type: string + readonly seq: number + readonly time_created: number + readonly time_updated: number + readonly data: string +} + +const lock = Semaphore.makeUnsafe(1) +const MIGRATION_STATE_KEY = "migration.v1-v2" +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info) +const decodePart = Schema.decodeUnknownOption(SessionV1.Part) +let runtimeState: RuntimeState = { status: "idle" } + +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 => 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 => 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() + 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> => { + 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 { + return Effect.gen(function* () { + const { db } = yield* Database.Service + if (!(yield* hasLegacySessions(db))) return { status: "completed" as const } + const state = yield* readState(db) + if (runtimeState.status === "running") return runtimeState + if (runtimeState.status === "error") return runtimeState + if (state?.phase === "completed") return { status: "completed" as const } + return { status: "required" as const } + }).pipe(Effect.orDie) +} + +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + runtimeState = { status: "running", progress: { label: "Clearing old events" } } + yield* run().pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Effect.sync(() => { + runtimeState = { status: "error", error: errorText(Cause.squash(cause)) } + }).pipe(Effect.andThen(Effect.logError("V1 migration failed", { cause }))), + onSuccess: () => + Effect.sync(() => { + runtimeState = { status: "idle" } + }), + }), + Effect.forkScoped({ startImmediately: true }), + ) + }), +) + +function errorText(input: unknown): string { + if (!(input instanceof Error)) return String(input) + const cause = input.cause + return cause === undefined ? input.message : `${input.message}\nCaused by: ${errorText(cause)}` +} + +function updateProgress(progress: Progress) { + if (runtimeState.status === "running") runtimeState = { status: "running", progress } +} + +export function run(options: Options = {}): Effect.Effect { + return lock.withPermit( + Effect.gen(function* () { + const { db } = yield* Database.Service + const state = yield* readState(db) + if (state?.phase === "completed") return { status: "completed" as const } + if (!(yield* hasLegacySessions(db))) return { status: "completed" as const } + const migrate = Effect.gen(function* () { + const now = Date.now() + yield* db.run(sql` + INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes) + VALUES (${Project.ID.global}, ${path.parse(Global.Path.data).root}, ${now}, ${now}, '[]') + `) + if (state === undefined) + yield* db + .transaction((tx) => + Effect.gen(function* () { + yield* tx.delete(EventTable).run() + yield* tx.insert(KVTable).values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } }).run() + }), + ) + .pipe(Effect.orDie) + const sourceTotal = yield* countNextSessions(nextPath(options)) + const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0 + const cursor = state?.phase === "sessions" ? state.cursor : undefined + const migrated = + cursor !== undefined + ? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`)) + ?.value ?? 0) + : 0 + const denominator = sourceTotal + legacyTotal + updateProgress({ label: "Migrating sessions", numerator: migrated, denominator }) + yield* importNextDatabase(db, nextPath(options), (completed) => { + updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator }) + }) + updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator }) + const projects = new Set((yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id)) + while (true) { + const state = yield* readState(db) + const cursorValue = state?.phase === "sessions" ? state.cursor : undefined + const nextID = yield* db.get<{ id: string; project_id: string }>( + cursorValue === undefined + ? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1` + : sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`, + ) + if (!nextID) break + yield* db + .transaction((tx) => + Effect.gen(function* () { + yield* tx + .insert(KVTable) + .values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } }) + .onConflictDoUpdate({ + target: KVTable.key, + set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() }, + }) + .run() + const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global + if (projectID !== nextID.project_id) + yield* Effect.logWarning("Reassigned V1 session with missing project", { + sessionID: nextID.id, + projectID: nextID.project_id, + }) + 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, ${projectID}, 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( + sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`, + ) + const sourceParts = yield* tx.all( + 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(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() + }), + ) + .pipe(Effect.orDie) + if (runtimeState.status === "running") + runtimeState = { + status: "running", + progress: { + label: "Migrating sessions", + numerator: (runtimeState.progress.numerator ?? 0) + 1, + denominator, + }, + } + yield* Effect.yieldNow + } + yield* db + .transaction((tx) => + Effect.gen(function* () { + yield* tx + .insert(KVTable) + .values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } }) + .onConflictDoUpdate({ + target: KVTable.key, + set: { value: { phase: "completed" }, time_updated: Date.now() }, + }) + .run() + }), + ) + .pipe(Effect.orDie) + return { status: "completed" as const } + }) + return yield* migrate + }).pipe(Effect.orDie), + ) +} + +function nextPath(options: Options) { + if (options.nextDatabasePath) return options.nextDatabasePath + if (process.env.OPENCODE_DB === ":memory:") return undefined + return path.join(Global.Path.data, "opencode-next.db") +} + +function openNextDatabase(sourcePath: string) { + return Effect.acquireRelease( + Effect.gen(function* () { + const sqlite = yield* Effect.promise(() => import("bun:sqlite")) + return new sqlite.Database(sourcePath, { readonly: true, strict: true }) + }), + (source) => Effect.sync(() => source.close()), + ) +} + +function countNextSessions(sourcePath: string | undefined) { + if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0) + return Effect.scoped( + Effect.gen(function* () { + const source = yield* openNextDatabase(sourcePath) + if (!isNextDatabase(source)) return 0 + return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0 + }), + ).pipe(Effect.orElseSucceed(() => 0)) +} + +function importNextDatabase( + db: Database.Interface["db"], + sourcePath: string | undefined, + onProgress: (completed: number) => void, +): Effect.Effect { + if (!sourcePath || !existsSync(sourcePath)) return Effect.void + return Effect.scoped( + Effect.gen(function* () { + const source = yield* openNextDatabase(sourcePath) + if (!isNextDatabase(source)) { + yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath }) + return + } + source.run("BEGIN") + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (source.inTransaction) source.run("ROLLBACK") + }), + ) + const projects = new Map( + source + .query("SELECT * FROM project") + .all() + .map((project) => [project.id, project]), + ) + const sessions = source.query("SELECT * FROM session ORDER BY id DESC").all() + for (const [index, session] of sessions.entries()) { + const project = projects.get(session.project_id) + const projectID = project ? session.project_id : Project.ID.global + if (!project) { + yield* Effect.logWarning("Reassigned previous V2 session with missing project", { + sessionID: session.id, + projectID: session.project_id, + }) + } + const messages = source + .query( + "SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq", + ) + .all(session.id) + yield* db + .transaction((tx) => + Effect.gen(function* () { + if (project) + yield* tx.run(sql` + INSERT OR IGNORE INTO project ( + id, worktree, vcs, name, icon_url, icon_url_override, icon_color, + time_created, time_updated, time_initialized, sandboxes, commands + ) VALUES ( + ${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url}, + ${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated}, + ${project.time_initialized}, ${project.sandboxes}, ${project.commands} + ) + `) + const existing = yield* tx + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.id, SessionSchema.ID.make(session.id))) + .get() + if (existing) return + yield* tx.run(sql` + INSERT INTO session_v2 ( + id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, 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, time_suspended + ) VALUES ( + ${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id}, + ${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory}, + ${session.path}, ${session.title}, ${session.version}, ${session.share_url}, + ${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files}, + ${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input}, + ${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read}, + ${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent}, + ${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting}, + ${session.time_archived}, ${session.time_suspended} + ) + `) + yield* Effect.forEach(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}, ${message.data} + ) + `), + ) + yield* tx + .insert(EventSequenceTable) + .values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 }) + .onConflictDoUpdate({ + target: EventSequenceTable.aggregate_id, + set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null }, + }) + .run() + }), + ) + .pipe(Effect.orDie) + onProgress(index + 1) + yield* Effect.yieldNow + } + source.run("COMMIT") + }), + ) +} + +function isNextDatabase(source: SQLiteDatabase) { + const tables = new Set( + source + .query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((table) => table.name), + ) + return tables.has("project") && tables.has("session") && tables.has("session_message") +} + +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) { + 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>, +) { + 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 readState(db: Database.Interface["db"]): Effect.Effect { + return db + .select({ value: KVTable.value }) + .from(KVTable) + .where(eq(KVTable.key, MIGRATION_STATE_KEY)) + .get() + .pipe( + Effect.map((row) => parseState(row?.value)), + Effect.orDie, + ) +} + +function parseState(input: unknown): MigrationState | undefined { + if (!input || typeof input !== "object" || !("phase" in input)) return + if (input.phase === "completed") return { phase: "completed" } + if (input.phase !== "sessions") return + if (!("cursor" in input) || input.cursor === undefined) return { phase: "sessions" } + if (typeof input.cursor === "string") return { phase: "sessions", cursor: input.cursor } +} + +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, + ) +} diff --git a/packages/core/src/event/sql.ts b/packages/core/src/event/sql.ts index 966003c3179..13e1d9f6857 100644 --- a/packages/core/src/event/sql.ts +++ b/packages/core/src/event/sql.ts @@ -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>().notNull(), }, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index d52c1564709..fc6fc41e0e7 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -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" @@ -194,14 +193,8 @@ export interface Interface { after?: number follow?: boolean }) => Stream.Stream - readonly switchAgent: (input: { - sessionID: SessionSchema.ID - agent: Agent.ID - }) => Effect.Effect - readonly switchModel: (input: { - sessionID: SessionSchema.ID - model: Model.Ref - }) => Effect.Effect + readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect + readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect readonly move: (input: { sessionID: SessionSchema.ID @@ -337,45 +330,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) @@ -531,8 +524,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))) @@ -712,23 +704,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) @@ -809,9 +801,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) @@ -910,12 +900,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, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 62514caf701..51ff426bc46 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -56,6 +56,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { const project = pipe( Match.type(), Match.discriminatorsExhaustive("type")({ + "session.created": () => Effect.void, "session.usage.recorded": () => Effect.void, "session.agent.selected": (event) => { return adapter.appendMessage( diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 7c5ea023967..5e12004d3e2 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -7,17 +7,16 @@ 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 @@ -47,73 +46,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 - 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 -} - -function partData(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"]): typeof PartTable.$inferInsert.data { - const { id: _, messageID: __, sessionID: ___, ...rest } = part - return rest as DeepMutable -} - -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({ @@ -419,34 +352,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 @@ -464,81 +402,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) diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index f75aa7718bb..331325b1d38 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -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 -type V1PartData = Omit export const SessionTable = sqliteTable( - "session", + "session_v2", { id: text().$type().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().primaryKey(), - session_id: text() - .$type() - .notNull() - .references(() => SessionTable.id, { onDelete: "cascade" }), - ...Timestamps, - data: text({ mode: "json" }).notNull().$type(), - }, - (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().primaryKey(), - message_id: text() - .$type() - .notNull() - .references(() => MessageTable.id, { onDelete: "cascade" }), - session_id: text().$type().notNull(), - ...Timestamps, - data: text({ mode: "json" }).notNull().$type(), - }, - (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", { diff --git a/packages/core/src/share/sql.ts b/packages/core/src/share/sql.ts deleted file mode 100644 index a7a08d0c025..00000000000 --- a/packages/core/src/share/sql.ts +++ /dev/null @@ -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, -}) diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts deleted file mode 100644 index b4ce31e5f09..00000000000 --- a/packages/core/src/v1/session.ts +++ /dev/null @@ -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 -export const ContextOverflowError = NamedError.create("ContextOverflowError", { - message: Schema.String, - responseBody: Schema.optional(Schema.String), -}) -export const ContentFilterError = NamedError.create("ContentFilterError", { message: Schema.String }) diff --git a/packages/core/test/bus.test.ts b/packages/core/test/bus.test.ts index 4f5515d41b7..f9563f9b7d6 100644 --- a/packages/core/test/bus.test.ts +++ b/packages/core/test/bus.test.ts @@ -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. */ diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 3d5c3f87594..5a58432d22d 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -4,46 +4,16 @@ import { fileURLToPath } from "url" import path from "path" import { SqliteClient } from "@effect/sql-sqlite-bun" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" -import { Effect, Layer, Schema } from "effect" -import { eq, inArray, sql } from "drizzle-orm" +import { Effect, Layer } from "effect" +import { sql } from "drizzle-orm" import { DatabaseMigration } from "@opencode-ai/core/database/migration" import { migrations } from "@opencode-ai/core/database/migration.gen" -import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage" -import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths" -import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order" -import eventSourcedSessionPendingMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input" -import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent" -import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera" -import simplifySessionPendingMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input" -import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events" -import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox" -import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state" -import genericSessionPendingMigration from "@opencode-ai/core/database/migration/20260709013000_generic_session_input" -import sessionPendingTableMigration from "@opencode-ai/core/database/migration/20260709190621_session_pending_table" -import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions" -import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork" -import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended" -import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync" -import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events" -import canonicalToolResultsMigration from "@opencode-ai/core/database/migration/20260722170000_canonical_tool_results" -import optionalSessionTitleMigration from "@opencode-ai/core/database/migration/20260730195856_optional_session_title" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { Bus } from "@opencode-ai/core/bus" -import { Project } from "@opencode-ai/core/project" -import { ProjectTable } from "@opencode-ai/core/project/sql" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { SessionSchema } from "@opencode-ai/core/session/schema" -import { SessionMessage } from "@opencode-ai/core/session/message" -import { SessionTable } from "@opencode-ai/core/session/sql" -import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata" -import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" import { Database } from "@opencode-ai/core/database/database" -import { SessionProjector } from "@opencode-ai/core/session/projector" -import { SessionV1 } from "@opencode-ai/core/v1/session" import { tmpdir } from "./fixture/tmpdir" +import type { SqlClient } from "effect/unstable/sql/SqlClient" +import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials" -const run = (effect: Effect.Effect) => +const run = (effect: Effect.Effect) => Effect.runPromise( effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped), ) @@ -51,291 +21,20 @@ const run = (effect: Effect.Effect) => const makeDb = EffectDrizzleSqlite.makeWithDefaults() describe("DatabaseMigration", () => { - test("migrates pre-launch V2 state in place", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run( - sql`CREATE TABLE 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)`, - ) - yield* db.run( - sql`CREATE TABLE 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)`, - ) - yield* db.run( - sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, created integer NOT NULL, type text NOT NULL, data text NOT NULL)`, - ) - yield* db.run( - sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL, owner_id text)`, - ) - yield* db.run( - sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL)`, - ) - const messages = [ - ["msg_skill", "skill", { name: "effect", text: "Use Effect", time: { created: 1 } }], - [ - "msg_shell", - "shell", - { - shell: { id: "sh_old", command: "pwd", status: "exited", exit: 0, cwd: "/tmp" }, - output: { output: "/tmp", cursor: 4, size: 4, truncated: false }, - time: { created: 2, completed: 3 }, - }, - ], - [ - "msg_assistant", - "assistant", - { - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [ - { - type: "tool", - id: "call_old", - name: "read", - provider: "removed", - state: { status: "pending", input: '{"path":"README.md"}', title: "removed" }, - time: { created: 3 }, - }, - ], - time: { created: 3 }, - }, - ], - [ - "msg_failed", - "compaction", - { - status: "failed", - reason: "manual", - summary: "removed", - recent: "removed", - time: { created: 4 }, - }, - ], - [ - "msg_queued", - "compaction", - { status: "queued", reason: "manual", summary: "", recent: "", time: { created: 5 } }, - ], - [ - "msg_synthetic", - "synthetic", - { sessionID: "ses_test", text: "context", description: "source", time: { created: 6 } }, - ], - [ - "msg_running", - "compaction", - { status: "running", reason: "auto", summary: "partial", recent: "recent", time: { created: 7 } }, - ], - [ - "msg_completed", - "compaction", - { status: "completed", reason: "auto", summary: "summary", recent: "recent", time: { created: 8 } }, - ], - ] as const - for (const [id, type, data] of messages) - yield* db.run( - sql`INSERT INTO session_message VALUES (${id}, 'ses_test', ${type}, 1, 10, 11, ${JSON.stringify(data)})`, - ) - yield* db.run( - sql`INSERT INTO session_input VALUES ('msg_queued', 'ses_test', 'compaction', NULL, NULL, 4, NULL, 5)`, - ) - yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_test', 9, 'owner')`) - yield* db.run(sql`INSERT INTO instruction_checkpoint VALUES ('ses_test', 'baseline', '{"source":"value"}', 7)`) - const events = [ - ["evt_skill", 1, 101, "session.skill.activated.1", { sessionID: "ses_test", name: "effect", text: "Use" }], - ["evt_started", 2, 102, "session.compaction.started.1", { sessionID: "ses_test", reason: "auto" }], - ["evt_delta", 3, 103, "session.compaction.delta.1", { sessionID: "ses_test", text: "partial" }], - ["evt_failed", 4, 104, "session.compaction.failed.1", { sessionID: "ses_test" }], - [ - "evt_revert", - 5, - 105, - "session.revert.staged.1", - { - sessionID: "ses_test", - revert: { - messageID: "msg_skill", - snapshot: "tree", - diff: "removed", - files: [{ path: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }], - }, - }, - ], - [ - "evt_skill_current", - 6, - 106, - "session.skill.activated.2", - { sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" }, - ], - ] as const - for (const [id, seq, created, type, data] of events) - yield* db.run( - sql`INSERT INTO event VALUES (${id}, 'ses_test', ${seq}, ${created}, ${type}, ${JSON.stringify(data)})`, - ) - - yield* DatabaseMigration.applyOnly(db, [migratePrelaunchV2StateMigration]) - - const rows = yield* db.all<{ - id: string - type: string - seq: number - time_created: number - time_updated: number - data: string - }>(sql`SELECT id, type, seq, time_created, time_updated, data FROM session_message ORDER BY id`) - for (const row of rows) - Schema.decodeUnknownSync(SessionMessage.Info)({ ...JSON.parse(row.data), id: row.id, type: row.type }) - expect(rows.every((row) => row.seq === 1 && row.time_created === 10 && row.time_updated === 11)).toBe(true) - expect(rows.map((row) => [row.id, JSON.parse(row.data)])).toEqual([ - [ - "msg_assistant", - expect.objectContaining({ - content: [expect.objectContaining({ state: { status: "streaming", input: '{"path":"README.md"}' } })], - }), - ], - ["msg_completed", expect.objectContaining({ status: "completed", summary: "summary", recent: "recent" })], - [ - "msg_failed", - { - time: { created: 4 }, - status: "failed", - reason: "manual", - error: { - type: "compaction.failed", - message: "Compaction failed before recording an error", - }, - }, - ], - ["msg_running", expect.objectContaining({ status: "running", summary: "partial", recent: "recent" })], - ["msg_shell", expect.objectContaining({ shellID: "sh_old", command: "pwd", status: "exited", exit: 0 })], - ["msg_skill", { time: { created: 1 }, skill: "effect", name: "effect", text: "Use Effect" }], - ["msg_synthetic", { time: { created: 6 }, text: "context", description: "source" }], - ]) - expect(yield* db.get(sql`SELECT * FROM session_input`)).toEqual({ - id: "msg_queued", - session_id: "ses_test", - type: "compaction", - prompt: null, - delivery: null, - admitted_seq: 4, - promoted_seq: null, - time_created: 5, - }) - const migratedEvents = yield* db.all<{ - id: string - aggregate_id: string - seq: number - created: number - type: string - data: string - }>(sql`SELECT * FROM event ORDER BY seq`) - expect(migratedEvents.map((event) => ({ ...event, data: JSON.parse(event.data) }))).toEqual([ - { - id: "evt_skill", - aggregate_id: "ses_test", - seq: 1, - created: 101, - type: "session.skill.activated.1", - data: { sessionID: "ses_test", id: "effect", name: "effect", text: "Use" }, - }, - { - id: "evt_started", - aggregate_id: "ses_test", - seq: 2, - created: 102, - type: "session.compaction.started.1", - data: { sessionID: "ses_test", reason: "auto", recent: "" }, - }, - { - id: "evt_failed", - aggregate_id: "ses_test", - seq: 4, - created: 104, - type: "session.compaction.failed.1", - data: { - sessionID: "ses_test", - reason: "auto", - error: { - type: "compaction.failed", - message: "Compaction failed before recording an error", - }, - }, - }, - { - id: "evt_revert", - aggregate_id: "ses_test", - seq: 5, - created: 105, - type: "session.revert.staged.1", - data: { - sessionID: "ses_test", - revert: { - messageID: "msg_skill", - snapshot: "tree", - files: [{ file: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }], - }, - }, - }, - { - id: "evt_skill_current", - aggregate_id: "ses_test", - seq: 6, - created: 106, - type: "session.skill.activated.1", - data: { sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" }, - }, - ]) - expect(yield* db.get(sql`SELECT * FROM event_sequence`)).toEqual({ - aggregate_id: "ses_test", - seq: 9, - owner_id: "owner", - }) - expect(yield* db.get(sql`SELECT * FROM instruction_checkpoint`)).toEqual({ - session_id: "ses_test", - baseline: "baseline", - snapshot: '{"source":"value"}', - baseline_seq: 7, - }) - }), - ) - }) - - test("resets incompatible V2 Session event history", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session_input (id text PRIMARY KEY)`) - yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY)`) - yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY)`) - yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`) - yield* db.run(sql`INSERT INTO session_input (id) VALUES ('input')`) - yield* db.run(sql`INSERT INTO session_message (id) VALUES ('message')`) - yield* db.run(sql`INSERT INTO event (id) VALUES ('event')`) - yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 1)`) - - yield* DatabaseMigration.applyOnly(db, [resetSessionEventsMigration]) - - expect(yield* db.get(sql`SELECT id FROM session_input`)).toBeUndefined() - expect(yield* db.get(sql`SELECT id FROM session_message`)).toBeUndefined() - expect(yield* db.get(sql`SELECT id FROM event`)).toBeUndefined() - expect(yield* db.get(sql`SELECT aggregate_id FROM event_sequence`)).toBeUndefined() - }), - ) - }) - test("serializes concurrent embedded initialization for one database path", async () => { await using tmp = await tmpdir() const filename = path.join(tmp.path, "embedded.sqlite") - const layers = [Database.layer({ path: filename }), Database.layer({ path: filename })] await Effect.runPromise( Effect.all( - layers.map((layer) => Effect.scoped(Layer.build(layer))), + [Database.layer({ path: filename }), Database.layer({ path: filename })].map((layer) => + Effect.scoped(Layer.build(layer)), + ), { concurrency: "unbounded" }, ), ) }) + if (process.platform === "linux") { test("declared schema has no ungenerated migrations", async () => { const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check` @@ -346,44 +45,21 @@ describe("DatabaseMigration", () => { }, 30_000) } - test("applies tracked migrations to an empty database", async () => { + test("bootstraps the current schema and records the migration registry", async () => { await run( Effect.gen(function* () { const db = yield* makeDb yield* DatabaseMigration.apply(db) - expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({ - name: "session", - }) - expect( - yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`), - ).toBeUndefined() + expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_v2'`)).toEqual( + { + name: "session_v2", + }, + ) expect( yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_pending'`), ).toEqual({ name: "session_pending" }) - expect( - yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'instruction_checkpoint'`), - ).toBeUndefined() - expect( - yield* db.all( - sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('instruction_blob', 'instruction_state') ORDER BY name`, - ), - ).toEqual([{ name: "instruction_blob" }, { name: "instruction_state" }]) - expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) - expect( - yield* db.all( - sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_pending_type_delivery_seq_idx', 'session_input_session_pending_compaction_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_pending_session_delivery_seq_idx', 'session_pending_session_compaction_idx', 'session_pending_session_admitted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`, - ), - ).toEqual([ - { name: "event_aggregate_seq_idx" }, - { name: "event_aggregate_type_seq_idx" }, - { name: "session_message_session_seq_idx" }, - { name: "session_message_session_time_created_id_idx" }, - { name: "session_message_session_type_seq_idx" }, - { name: "session_pending_session_admitted_seq_idx" }, - { name: "session_pending_session_compaction_idx" }, - { name: "session_pending_session_delivery_seq_idx" }, - ]) + expect(yield* db.get(sql`SELECT count(*) AS count FROM migration`)).toEqual({ count: migrations.length }) }), ) }) @@ -400,1029 +76,150 @@ describe("DatabaseMigration", () => { ).rejects.toThrow("Database is not empty and has no session table") }) - test("makes session titles nullable without deleting dependent rows", async () => { + test("applies generic migrations once and records their order", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) + const input = [ + { + id: "first", + up: (tx: Parameters[0]>[0]) => + tx.run(sql`CREATE TABLE applied (id text PRIMARY KEY)`), + }, + { + id: "second", + up: (tx: Parameters[0]>[0]) => + tx.run(sql`INSERT INTO applied (id) VALUES ('second')`), + }, + ] + + yield* DatabaseMigration.applyOnly(db, input) + yield* DatabaseMigration.applyOnly(db, input) + + expect(yield* db.all(sql`SELECT id FROM applied`)).toEqual([{ id: "second" }]) + expect(yield* db.all(sql`SELECT id FROM migration ORDER BY time_completed, id`)).toEqual([ + { id: "first" }, + { id: "second" }, + ]) + }), + ) + }) + + test("imports legacy JSON credentials without changing the source file or existing credentials", async () => { + await using tmp = await tmpdir() + const source = path.join(tmp.path, "auth.json") + const content = JSON.stringify({ + openai: { type: "oauth", refresh: "refresh", access: "access", expires: 123, accountId: "account" }, + anthropic: { type: "api", key: "legacy-key", metadata: { region: "us" } }, + "https://example.com/": { type: "wellknown", key: "TOKEN", token: "wellknown-key" }, + invalid: { type: "unknown" }, + }) + await Bun.write(source, content) + + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + const now = Date.now() + yield* db.run(sql` + INSERT INTO credential (id, integration_id, label, value, time_created, time_updated) + VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now}) + `) + + yield* db.transaction((tx) => importLegacyCredentials(tx, source)) + + expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual( + [ + { + integration_id: "anthropic", + label: "Existing", + value: JSON.stringify({ type: "key", key: "current-key" }), + }, + { + integration_id: "https://example.com", + label: "default", + value: JSON.stringify({ type: "key", key: "wellknown-key" }), + }, + { + integration_id: "openai", + label: "default", + value: JSON.stringify({ + type: "oauth", + methodID: "chatgpt-browser", + refresh: "refresh", + access: "access", + expires: 123, + metadata: { accountID: "account" }, + }), + }, + ], + ) + expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'wellknown:sources'`)).toEqual({ + value: JSON.stringify(["https://example.com"]), + }) + }), + ) + + expect(await Bun.file(source).text()).toBe(content) + }) + + test("rolls back a failed migration without recording it", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) + const migration = { + id: "failing", + up: (tx: Parameters[0]>[0]) => + Effect.gen(function* () { + yield* tx.run(sql`CREATE TABLE rolled_back (id text PRIMARY KEY)`) + yield* Effect.fail(new Error("stop")) + }), + } + + expect((yield* Effect.exit(DatabaseMigration.applyOnly(db, [migration])))._tag).toBe("Failure") + expect( + yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'rolled_back'`), + ).toBeUndefined() + expect(yield* db.get(sql`SELECT id FROM migration WHERE id = 'failing'`)).toBeUndefined() + }), + ) + }) + + test("suspends foreign keys outside migrations that rebuild referenced tables", async () => { await run( Effect.gen(function* () { const db = yield* makeDb yield* db.run(sql`PRAGMA foreign_keys = ON`) - yield* db.run(sql` - CREATE TABLE session ( - id text PRIMARY KEY, - title text NOT NULL - ) - `) - yield* db.run(sql` - CREATE TABLE message ( - id text PRIMARY KEY, - session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE - ) - `) - yield* db.run(sql`INSERT INTO session VALUES ('ses_existing', 'Existing title')`) - yield* db.run(sql`INSERT INTO message VALUES ('msg_existing', 'ses_existing')`) + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, title text NOT NULL)`) + yield* db.run( + sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE)`, + ) + yield* db.run(sql`INSERT INTO session VALUES ('session', 'title')`) + yield* db.run(sql`INSERT INTO message VALUES ('message', 'session')`) - yield* DatabaseMigration.applyOnly(db, [optionalSessionTitleMigration]) + yield* DatabaseMigration.applyOnly(db, [ + { + id: "rebuild", + foreignKeys: false, + up: (tx) => + Effect.gen(function* () { + yield* tx.run(sql`CREATE TABLE next_session (id text PRIMARY KEY, title text)`) + yield* tx.run(sql`INSERT INTO next_session SELECT * FROM session`) + yield* tx.run(sql`DROP TABLE session`) + yield* tx.run(sql`ALTER TABLE next_session RENAME TO session`) + }), + }, + ]) - expect(yield* db.get(sql`SELECT title FROM session WHERE id = 'ses_existing'`)).toEqual({ - title: "Existing title", - }) - expect(yield* db.get(sql`SELECT id FROM message WHERE id = 'msg_existing'`)).toEqual({ id: "msg_existing" }) - expect( - yield* db.get<{ notnull: number }>(sql`SELECT "notnull" FROM pragma_table_info('session') WHERE name = 'title'`), - ).toEqual({ notnull: 0 }) + expect(yield* db.get(sql`SELECT id FROM message`)).toEqual({ id: "message" }) expect(yield* db.get<{ foreign_keys: number }>(sql`PRAGMA foreign_keys`)).toEqual({ foreign_keys: 1 }) }), ) }) - test("backfills existing Context Epoch rows to the build agent", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run( - sql`CREATE TABLE session_context_epoch (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL, replacement_seq integer, revision integer DEFAULT 0 NOT NULL)`, - ) - yield* db.run( - sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('ses_existing', 'baseline', '{}', 0)`, - ) - - yield* DatabaseMigration.applyOnly(db, [contextEpochAgentMigration]) - - expect(yield* db.get(sql`SELECT agent FROM session_context_epoch WHERE session_id = 'ses_existing'`)).toEqual({ - agent: "build", - }) - }), - ) - }) - - test("separates existing fork provenance from subagent hierarchy", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, parent_id text)`) - yield* db.run( - sql`CREATE TABLE event (aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, - ) - yield* db.run(sql`INSERT INTO session VALUES ('ses_source', NULL), ('ses_fork', 'ses_source')`) - yield* db.run( - sql`INSERT INTO event VALUES ('ses_fork', 0, 'session.forked', '{"sessionID":"ses_fork","parentID":"ses_source","from":"msg_boundary"}')`, - ) - - yield* DatabaseMigration.applyOnly(db, [addSessionForkMigration]) - - expect( - yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_fork'`), - ).toEqual({ - parent_id: null, - fork_session_id: "ses_source", - fork_message_id: "msg_boundary", - }) - expect( - yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_source'`), - ).toEqual({ - parent_id: null, - fork_session_id: null, - fork_message_id: null, - }) - }), - ) - }) - - test("does not infer restart continuation from historical shutdown events", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) - yield* db.run( - sql`CREATE TABLE event (aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, - ) - yield* db.run(sql`INSERT INTO session VALUES ('ses_shutdown')`) - yield* db.run( - sql`INSERT INTO event VALUES ('ses_shutdown', 0, 'session.execution.interrupted.1', '{"reason":"shutdown"}')`, - ) - - yield* DatabaseMigration.applyOnly(db, [timeSuspendedMigration]) - - expect(yield* db.get(sql`SELECT time_suspended FROM session WHERE id = 'ses_shutdown'`)).toEqual({ - time_suspended: null, - }) - }), - ) - }) - - test("renames instruction state without losing rows or durable updates", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) - yield* db.run( - sql`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, PRIMARY KEY(session_id, key))`, - ) - yield* db.run( - sql`CREATE TABLE session_context_epoch (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL)`, - ) - yield* db.run(sql`CREATE TABLE event (type text NOT NULL)`) - yield* db.run(sql`INSERT INTO session_context_entry VALUES ('ses_test', 'plan', '"ready"', 1, 2)`) - yield* db.run(sql`INSERT INTO session_context_epoch VALUES ('ses_test', 'baseline', '{}', 7)`) - yield* db.run(sql`INSERT INTO event VALUES ('session.context.updated.1')`) - - yield* DatabaseMigration.applyOnly(db, [renameInstructionsMigration]) - - expect(yield* db.get(sql`SELECT * FROM instruction_entry`)).toEqual({ - session_id: "ses_test", - key: "plan", - value: '"ready"', - time_created: 1, - time_updated: 2, - }) - expect(yield* db.get(sql`SELECT * FROM instruction_checkpoint`)).toEqual({ - session_id: "ses_test", - baseline: "baseline", - snapshot: "{}", - baseline_seq: 7, - }) - expect(yield* db.get(sql`SELECT type FROM event`)).toEqual({ type: "session.instructions.updated.1" }) - }), - ) - }) - - test("deletes pre-beta instruction events and projected System messages", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, fork_session_id text)`) - yield* db.run( - sql`CREATE TABLE instruction_entry (session_id text NOT NULL, key text NOT NULL, value text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, PRIMARY KEY(session_id, key))`, - ) - yield* db.run(sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY)`) - yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`) - yield* db.run( - sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, - ) - yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, type text NOT NULL)`) - yield* db.run(sql`INSERT INTO session VALUES ('ses_test', NULL)`) - yield* db.run( - sql`INSERT INTO event VALUES ('evt_instruction', 'ses_test', 0, 'session.instructions.updated.1', '{"sessionID":"ses_test","text":"changed"}')`, - ) - yield* db.run( - sql`INSERT INTO event VALUES ('evt_other', 'ses_test', 1, 'session.synthetic.1', '{"sessionID":"ses_test","text":"keep"}')`, - ) - yield* db.run( - sql`INSERT INTO session_message VALUES ('msg_instruction', 'system'), ('msg_other', 'system'), ('msg_user', 'user')`, - ) - yield* db.run(sql`INSERT INTO instruction_entry VALUES ('ses_test', 'plan', '"ready"', 1, 2)`) - - yield* DatabaseMigration.applyOnly(db, [instructionSyncMigration]) - - expect(yield* db.all(sql`SELECT id, type FROM event`)).toEqual([ - { id: "evt_other", type: "session.synthetic.1" }, - ]) - expect(yield* db.all(sql`SELECT id, type FROM session_message ORDER BY id`)).toEqual([ - { id: "msg_user", type: "user" }, - ]) - expect(yield* db.get(sql`SELECT * FROM instruction_entry`)).toEqual({ - session_id: "ses_test", - key: "plan", - value: '"ready"', - removed: 0, - time_created: 1, - time_updated: 2, - }) - expect( - yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'instruction_checkpoint'`), - ).toBeUndefined() - }), - ) - }) - - test("deletes durable tool progress without changing aggregate sequence watermarks", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`) - yield* db.run( - sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, - ) - yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_test', 5)`) - yield* db.run(sql`INSERT INTO event VALUES ('evt_success', 'ses_test', 4, 'session.tool.success.1', '{}')`) - yield* db.run(sql`INSERT INTO event VALUES ('evt_progress', 'ses_test', 5, 'session.tool.progress.1', '{}')`) - - yield* DatabaseMigration.applyOnly(db, [deleteToolProgressEventsMigration]) - - expect(yield* db.all(sql`SELECT id, seq, type, data FROM event ORDER BY seq`)).toEqual([ - { id: "evt_success", seq: 4, type: "session.tool.success.1", data: "{}" }, - ]) - expect(yield* db.get(sql`SELECT aggregate_id, seq FROM event_sequence`)).toEqual({ - aggregate_id: "ses_test", - seq: 5, - }) - }), - ) - }) - - test("rewrites projected tool rows into the canonical result shape", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run( - sql`CREATE TABLE 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)`, - ) - yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, type text NOT NULL, data text NOT NULL)`) - const assistant = { - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [ - { type: "text", text: "before" }, - { - type: "tool", - id: "call_content", - name: "grep", - state: { - status: "completed", - input: { pattern: "TODO" }, - content: [{ type: "text", text: "src/a.ts:1: TODO" }], - structured: { value: [{ file: "src/a.ts", line: 1 }] }, - }, - time: { created: 1, completed: 2 }, - }, - { - type: "tool", - id: "call_structured_only", - name: "read", - state: { - status: "completed", - input: { path: "README.md" }, - content: [], - structured: { text: "hello" }, - }, - time: { created: 1, completed: 2 }, - }, - { - type: "tool", - id: "call_hosted", - name: "web_search", - executed: true, - providerResultState: { blockType: "web_search_tool_result" }, - state: { - status: "completed", - input: { query: "effect" }, - content: [], - structured: {}, - result: { type: "json", value: [{ url: "https://example.com" }] }, - }, - time: { created: 1, completed: 2 }, - }, - { - type: "tool", - id: "call_failed", - name: "shell", - state: { - status: "error", - input: { command: "sleep 99" }, - error: { type: "tool.execution", message: "timed out" }, - content: [{ type: "text", text: "partial output" }], - structured: { truncated: false }, - result: { type: "error", value: "timed out" }, - }, - time: { created: 1, completed: 2 }, - }, - { - type: "tool", - id: "call_running", - name: "shell", - state: { - status: "running", - input: { command: "sleep 1" }, - structured: { truncated: false }, - content: [{ type: "text", text: "tick" }], - }, - time: { created: 1, ran: 2 }, - }, - ], - time: { created: 1 }, - } - yield* db.run( - sql`INSERT INTO session_message VALUES ('msg_tools', 'ses_test', 'assistant', 1, 10, 11, ${JSON.stringify(assistant)})`, - ) - yield* db.run( - sql`INSERT INTO session_message VALUES ('msg_user', 'ses_test', 'user', 2, 12, 13, '{"text":"hi","time":{"created":1}}')`, - ) - // A row that never decoded must be skipped, not fail the migration. - yield* db.run( - sql`INSERT INTO session_message VALUES ('msg_corrupt', 'ses_test', 'assistant', 3, 14, 15, 'not json')`, - ) - yield* db.run( - sql`INSERT INTO event VALUES ('evt_success', 'session.tool.success.1', ${JSON.stringify({ - sessionID: "ses_test", - assistantMessageID: "msg_tools", - id: "call_hosted", - structured: {}, - content: [], - result: { type: "json", value: [{ url: "https://example.com" }] }, - executed: true, - })})`, - ) - yield* db.run( - sql`INSERT INTO event VALUES ('evt_failed', 'session.tool.failed.1', ${JSON.stringify({ - sessionID: "ses_test", - assistantMessageID: "msg_tools", - id: "call_failed", - error: { type: "tool.execution", message: "timed out" }, - metadata: { truncated: false }, - executed: false, - })})`, - ) - - yield* DatabaseMigration.applyOnly(db, [canonicalToolResultsMigration]) - - const row = yield* db.get<{ data: string }>(sql`SELECT data FROM session_message WHERE id = 'msg_tools'`) - const migrated = JSON.parse(row!.data) - // Every migrated row must decode with the current schema; reload hard-fails otherwise. - Schema.decodeUnknownSync(SessionMessage.Info)({ ...migrated, id: "msg_tools", type: "assistant" }) - const states = new Map( - migrated.content.flatMap((part: { type: string; id?: string }) => - part.type === "tool" ? [[part.id, part]] : [], - ), - ) - expect(states.get("call_content")).toMatchObject({ - state: { - status: "completed", - input: { pattern: "TODO" }, - content: [{ type: "text", text: "src/a.ts:1: TODO" }], - // Old generic structured payloads survive as canonical metadata. - metadata: { value: [{ file: "src/a.ts", line: 1 }] }, - }, - }) - expect((states.get("call_content") as { state: Record }).state).not.toHaveProperty( - "structured", - ) - expect(states.get("call_structured_only")).toMatchObject({ - state: { - status: "completed", - content: [{ type: "text", text: JSON.stringify({ text: "hello" }, null, 2) }], - metadata: { text: "hello" }, - }, - }) - expect(states.get("call_hosted")).toMatchObject({ - executed: true, - providerResultState: { - blockType: "web_search_tool_result", - result: [{ url: "https://example.com" }], - }, - state: { - status: "completed", - content: [{ type: "text", text: JSON.stringify([{ url: "https://example.com" }], null, 2) }], - }, - }) - expect(states.get("call_failed")).toMatchObject({ - state: { - status: "error", - error: { type: "tool.execution", message: "timed out" }, - content: [{ type: "text", text: "partial output" }], - metadata: { truncated: false }, - }, - }) - const failedState = (states.get("call_failed") as { state: Record }).state - expect(failedState).not.toHaveProperty("result") - expect(failedState).not.toHaveProperty("structured") - expect(states.get("call_running")).toMatchObject({ - state: { - status: "running", - metadata: { truncated: false }, - }, - }) - const event = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_success'`) - expect(event!.type).toBe("session.tool.success.1") - expect(JSON.parse(event!.data)).toEqual({ - sessionID: "ses_test", - assistantMessageID: "msg_tools", - id: "call_hosted", - structured: {}, - content: [], - result: { type: "json", value: [{ url: "https://example.com" }] }, - executed: true, - }) - const failedEvent = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_failed'`) - expect(failedEvent!.type).toBe("session.tool.failed.1") - expect(JSON.parse(failedEvent!.data)).toEqual({ - sessionID: "ses_test", - assistantMessageID: "msg_tools", - id: "call_failed", - error: { type: "tool.execution", message: "timed out" }, - metadata: { truncated: false }, - executed: false, - }) - expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_user'`)).toEqual({ - data: '{"text":"hi","time":{"created":1}}', - }) - expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_corrupt'`)).toEqual({ - data: "not json", - }) - }), - ) - }) - - test("records the authoritative parent sequence on existing forks", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, fork_session_id text)`) - yield* db.run( - sql`CREATE TABLE instruction_entry (session_id text NOT NULL, key text NOT NULL, value text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, PRIMARY KEY(session_id, key))`, - ) - yield* db.run(sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY)`) - yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, type text NOT NULL)`) - yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`) - yield* db.run( - sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, - ) - yield* db.run(sql`INSERT INTO session VALUES ('ses_child', 'ses_parent')`) - yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_child', 8)`) - yield* db.run( - sql`INSERT INTO event VALUES ('evt_fork', 'ses_child', 0, 'session.forked.1', '{"sessionID":"ses_child","parentID":"ses_parent"}')`, - ) - yield* db.run( - sql`INSERT INTO event VALUES ('evt_instruction', 'ses_child', 5, 'session.instructions.updated.1', '{"sessionID":"ses_child","text":"changed"}')`, - ) - yield* db.run(sql`INSERT INTO event VALUES ('evt_input', 'ses_child', 6, 'session.input.admitted.1', '{}')`) - - yield* DatabaseMigration.applyOnly(db, [instructionSyncMigration]) - - expect(yield* db.get(sql`SELECT fork_seq FROM session`)).toEqual({ fork_seq: 4 }) - expect(yield* db.get(sql`SELECT type, data FROM event WHERE seq = 0`)).toEqual({ - type: "session.forked.2", - data: '{"sessionID":"ses_child","parentID":"ses_parent","parentSeq":4}', - }) - expect(yield* db.get(sql`SELECT id FROM event WHERE id = 'evt_instruction'`)).toBeUndefined() - }), - ) - }) - - test("keeps legacy credential fields nullable", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run( - sql`CREATE TABLE credential (id text PRIMARY KEY, connector_id text NOT NULL, method_id text NOT NULL, label text NOT NULL, value text NOT NULL, active integer DEFAULT false NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL)`, - ) - yield* db.run( - sql`CREATE UNIQUE INDEX credential_connector_active_idx ON credential (connector_id) WHERE active = 1`, - ) - yield* DatabaseMigration.applyOnly(db, [simplifyIntegrationCredentialsMigration]) - - yield* db.run( - sql`INSERT INTO credential (id, connector_id, method_id, label, value, active, time_created, time_updated) VALUES ('legacy', 'openai', 'oauth', 'Legacy', '{}', 1, 1, 1)`, - ) - yield* db.run( - sql`INSERT INTO credential (id, integration_id, label, value, time_created, time_updated) VALUES ('current', 'anthropic', 'Current', '{}', 2, 2)`, - ) - expect(yield* db.get(sql`SELECT connector_id, method_id, active FROM credential WHERE id = 'current'`)).toEqual( - { connector_id: null, method_id: null, active: null }, - ) - }), - ) - }) - - test("resets beta history and rebuilds event-sourced Session input storage", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, workspace_id text)`) - yield* db.run(sql`CREATE TABLE workspace (id text PRIMARY KEY)`) - yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY)`) - yield* db.run(sql`CREATE TABLE part (id text PRIMARY KEY)`) - yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`) - yield* db.run( - sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, - ) - yield* db.run(sql`CREATE INDEX event_aggregate_seq_idx ON event (aggregate_id, seq)`) - yield* db.run(sql`CREATE INDEX event_aggregate_type_seq_idx ON event (aggregate_id, type, seq)`) - yield* db.run( - sql`CREATE TABLE 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)`, - ) - yield* db.run(sql`CREATE INDEX session_message_session_seq_idx ON session_message (session_id, seq)`) - yield* db.run( - sql`CREATE TABLE session_input (seq integer PRIMARY KEY AUTOINCREMENT, id text NOT NULL UNIQUE, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, promoted_seq integer, time_created integer NOT NULL)`, - ) - yield* db.run( - sql`CREATE INDEX session_input_session_pending_delivery_seq_idx ON session_input (session_id, promoted_seq, delivery, seq)`, - ) - yield* db.run(sql`INSERT INTO session (id, workspace_id) VALUES ('session', 'wrk_old')`) - yield* db.run(sql`INSERT INTO workspace (id) VALUES ('wrk_old')`) - yield* db.run(sql`INSERT INTO message (id) VALUES ('message')`) - yield* db.run(sql`INSERT INTO part (id) VALUES ('part')`) - yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 0)`) - yield* db.run( - sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('evt_old', 'session', 0, 'old.1', '{}')`, - ) - yield* db.run( - sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_old', 'session', 'user', 0, 1, 1, '{}')`, - ) - yield* db.run( - sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('msg_pending', 'session', '{}', 'steer', 1)`, - ) - - yield* DatabaseMigration.applyOnly(db, [eventSourcedSessionPendingMigration]) - - expect(yield* db.all(sql`SELECT id, workspace_id FROM session`)).toEqual([ - { id: "session", workspace_id: null }, - ]) - expect(yield* db.all(sql`SELECT id FROM workspace`)).toEqual([]) - expect(yield* db.all(sql`SELECT id FROM message`)).toEqual([{ id: "message" }]) - expect(yield* db.all(sql`SELECT id FROM part`)).toEqual([{ id: "part" }]) - expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([]) - expect(yield* db.all(sql`SELECT aggregate_id FROM event_sequence`)).toEqual([]) - expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([]) - expect(yield* db.all(sql`SELECT id FROM session_input`)).toEqual([]) - expect( - (yield* db.all<{ name: string }>(sql`PRAGMA table_info(session_input)`)).map((column) => column.name), - ).toEqual(["id", "session_id", "prompt", "delivery", "admitted_seq", "promoted_seq", "time_created"]) - expect( - (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_message)`)).find( - (index) => index.name === "session_message_session_seq_idx", - ), - ).toMatchObject({ unique: 1 }) - expect( - (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(event)`)).find( - (index) => index.name === "event_aggregate_seq_idx", - ), - ).toMatchObject({ unique: 1 }) - expect( - (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_input)`)).filter((index) => - ["session_input_session_admitted_seq_idx", "session_input_session_promoted_seq_idx"].includes(index.name), - ), - ).toEqual([ - expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }), - expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }), - ]) - }), - ) - }) - - test("preserves canonical V1 state and restarts its event stream", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`PRAGMA foreign_keys = ON`) - yield* DatabaseMigration.apply(db) - yield* db.run( - sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/project', 1, 1, '[]')`, - ) - yield* db.run( - sql`INSERT INTO workspace (id, type, project_id, time_used) VALUES ('workspace', 'local', 'global', 1)`, - ) - yield* db.run( - sql`INSERT INTO session (id, project_id, workspace_id, slug, directory, title, version, time_created, time_updated) VALUES ('session', 'global', 'workspace', 'session', '/project', 'Before', 'test', 1, 1)`, - ) - yield* db.run( - sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('message', 'session', 1, 1, '{}')`, - ) - yield* db.run( - sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('part', 'message', 'session', 1, 1, '{}')`, - ) - yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 9)`) - yield* db.run( - sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`, - ) - yield* db.run( - sql`INSERT INTO session_pending (id, session_id, type, data, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'user', '{}', 'steer', 9, 1)`, - ) - yield* db.run( - sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`, - ) - yield* db.run(sql`CREATE TABLE session_context_epoch (session_id text PRIMARY KEY)`) - // The partial compaction index embeds the qualified table name, so it - // must drop before the historical rename dance and recreate after. - yield* db.run(sql`DROP INDEX session_pending_session_compaction_idx`) - yield* db.run(sql`ALTER TABLE session_pending RENAME TO session_input`) - yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionPendingMigration.id}`) - yield* DatabaseMigration.applyOnly(db, [simplifySessionPendingMigration]) - yield* db.run(sql`DROP TABLE session_context_epoch`) - yield* db.run(sql`ALTER TABLE session_input RENAME TO session_pending`) - yield* db.run( - sql`CREATE UNIQUE INDEX session_pending_session_compaction_idx ON session_pending (session_id) WHERE "session_pending"."type" = 'compaction'`, - ) - - const database = Layer.succeed(Database.Service, { db }) - yield* Bus.Service.use((service) => - service.publish(SessionV1.Event.Updated, { - sessionID: SessionSchema.ID.make("session"), - info: { - id: SessionSchema.ID.make("session"), - slug: "session", - projectID: Project.ID.global, - directory: "/project", - title: "After", - version: "test", - time: { created: 1, updated: 2 }, - }, - }), - ).pipe( - Effect.provide( - AppNodeBuilder.build(LayerNode.group([Bus.node, SessionProjector.node]), [ - [Database.node, database], - [Bus.node, Bus.configured({ persist: true })], - ]), - ), - ) - - expect( - yield* db.get(sql` - SELECT - (SELECT title FROM session WHERE id = 'session') AS title, - (SELECT workspace_id FROM session WHERE id = 'session') AS workspaceID, - (SELECT COUNT(*) FROM message WHERE id = 'message') AS messages, - (SELECT COUNT(*) FROM part WHERE id = 'part') AS parts, - (SELECT COUNT(*) FROM workspace) AS workspaces, - (SELECT COUNT(*) FROM session_pending) AS sessionInputs, - (SELECT COUNT(*) FROM session_message) AS sessionMessages, - (SELECT COUNT(*) FROM instruction_state) AS instructionStates, - (SELECT seq FROM event_sequence WHERE aggregate_id = 'session') AS seq, - (SELECT type FROM event WHERE aggregate_id = 'session') AS eventType - `), - ).toEqual({ - title: "After", - workspaceID: null, - messages: 1, - parts: 1, - workspaces: 0, - sessionInputs: 0, - sessionMessages: 0, - instructionStates: 0, - seq: 0, - eventType: "session.updated.1", - }) - }), - ) - }) - - test("preserves admitted prompts while generalizing the durable inbox", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run( - sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`, - ) - yield* db.run( - sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('input', 'session', '{"text":"hello"}', 'steer', 4, NULL, 1)`, - ) - - yield* DatabaseMigration.applyOnly(db, [durableSessionInboxMigration]) - - expect( - yield* db.all( - sql`SELECT id, type, prompt, delivery, admitted_seq, promoted_seq FROM session_input ORDER BY admitted_seq`, - ), - ).toEqual([ - { - id: "input", - type: "prompt", - prompt: '{"text":"hello"}', - delivery: "steer", - admitted_seq: 4, - promoted_seq: null, - }, - ]) - }), - ) - }) - - test("migrates prompt inbox rows and lifecycle events to generic user input", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) - yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`) - yield* db.run( - sql`CREATE TABLE 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)`, - ) - yield* db.run( - sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('input', 'session', 'prompt', '{"text":"hello"}', 'queue', 4, NULL, 1)`, - ) - yield* db.run( - sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('empty', 'session', 'prompt', NULL, 'steer', 6, NULL, 2)`, - ) - yield* db.run( - sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, created integer NOT NULL, type text NOT NULL, data text NOT NULL)`, - ) - yield* db.run( - sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('admitted', 'session', 4, 1, 'session.prompt.admitted.1', '{"sessionID":"session","inputID":"input","prompt":{"text":"hello"},"delivery":"queue"}')`, - ) - yield* db.run( - sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('promoted', 'session', 5, 2, 'session.prompt.promoted.1', '{"sessionID":"session","inputID":"input"}')`, - ) - yield* db.run( - sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('empty-admitted', 'session', 6, 2, 'session.prompt.admitted.1', '{"sessionID":"session","inputID":"empty","prompt":null,"delivery":"steer"}')`, - ) - yield* db.run( - sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('empty-promoted', 'session', 7, 2, 'session.prompt.promoted.1', '{"sessionID":"session","inputID":"empty"}')`, - ) - - yield* DatabaseMigration.applyOnly(db, [genericSessionPendingMigration]) - - expect(yield* db.all(sql`SELECT id, type, data, delivery FROM session_input ORDER BY admitted_seq`)).toEqual([ - { id: "input", type: "user", data: '{"text":"hello"}', delivery: "queue" }, - ]) - expect(yield* db.all(sql`SELECT type, data FROM event ORDER BY seq`)).toEqual([ - { - type: "session.input.admitted.1", - data: '{"sessionID":"session","inputID":"input","input":{"type":"user","data":{"text":"hello"},"delivery":"queue"}}', - }, - { - type: "session.input.promoted.1", - data: '{"sessionID":"session","inputID":"input"}', - }, - ]) - }), - ) - }) - - test("replaces the durable inbox with the empty session_pending table", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) - yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`) - yield* db.run( - sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE, type text NOT NULL, data text NOT NULL, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`, - ) - // Interim v2 builds shipped differing index sets on real databases; - // dropping the table removes whatever variant exists. - yield* db.run( - sql`CREATE INDEX session_input_session_pending_type_delivery_seq_idx ON session_input (session_id, promoted_seq, type, delivery, admitted_seq)`, - ) - yield* db.run( - sql`INSERT INTO session_input (id, session_id, type, data, delivery, admitted_seq, promoted_seq, time_created) VALUES ('pending', 'session', 'user', '{"text":"hello"}', 'steer', 4, NULL, 1)`, - ) - - yield* DatabaseMigration.applyOnly(db, [sessionPendingTableMigration]) - - expect( - yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`), - ).toBeUndefined() - expect(yield* db.all(sql`SELECT id FROM session_pending`)).toEqual([]) - expect( - (yield* db.all<{ name: string }>(sql`PRAGMA table_info(session_pending)`)).map((column) => column.name), - ).toEqual(["id", "session_id", "type", "data", "delivery", "admitted_seq", "time_created"]) - expect( - (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_pending)`)) - .filter((index) => index.name.startsWith("session_")) - .map((index) => ({ name: index.name, unique: index.unique })) - .sort((a, b) => a.name.localeCompare(b.name)), - ).toEqual([ - { name: "session_pending_session_admitted_seq_idx", unique: 1 }, - { name: "session_pending_session_compaction_idx", unique: 1 }, - { name: "session_pending_session_delivery_seq_idx", unique: 0 }, - ]) - }), - ) - }) - - test("resets incompatible projected Session messages before adding sequence order", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) - yield* db.run( - sql`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)`, - ) - yield* db.run( - sql`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)`, - ) - yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`) - yield* db.run( - sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, - ) - yield* db.run( - sql`CREATE INDEX session_message_session_time_created_id_idx ON session_message (session_id, time_created, id)`, - ) - yield* db.run( - sql`CREATE INDEX session_message_session_type_time_created_id_idx ON session_message (session_id, type, time_created, id)`, - ) - yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`) - yield* db.run( - sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('legacy_message', 'session', 1, 1, '{"role":"user"}')`, - ) - yield* db.run( - sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('legacy_part', 'legacy_message', 'session', 1, 1, '{"type":"text","text":"hello"}')`, - ) - yield* db.run( - sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('stale_projection', 'session', 'user', 1, 1, '{}')`, - ) - - yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration]) - - expect(yield* db.all(sql`SELECT id, session_id, data FROM message`)).toEqual([ - { id: "legacy_message", session_id: "session", data: '{"role":"user"}' }, - ]) - expect(yield* db.all(sql`SELECT id, message_id, session_id, data FROM part`)).toEqual([ - { - id: "legacy_part", - message_id: "legacy_message", - session_id: "session", - data: '{"type":"text","text":"hello"}', - }, - ]) - expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([]) - - yield* db.run( - sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('fresh_projection', 'session', 'user', 7, 2, 2, '{}')`, - ) - expect(yield* db.get(sql`SELECT id, seq FROM session_message`)).toEqual({ id: "fresh_projection", seq: 7 }) - }), - ) - }) - - test("runs session usage backfill in order with schema changes", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, time_updated integer NOT NULL)`) - yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`) - yield* db.run(sql`INSERT INTO session (id, time_updated) VALUES ('session_1', 1)`) - yield* db.run( - sql`INSERT INTO message (id, session_id, data) VALUES ('message_1', 'session_1', '{"role":"assistant","cost":1.25,"tokens":{"input":2,"output":3,"reasoning":4,"cache":{"read":5,"write":6}}}')`, - ) - - yield* DatabaseMigration.applyOnly(db, [sessionUsageMigration]) - - expect( - yield* db.get( - sql`SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write FROM session WHERE id = 'session_1'`, - ), - ).toEqual({ - cost: 1.25, - tokens_input: 2, - tokens_output: 3, - tokens_reasoning: 4, - tokens_cache_read: 5, - tokens_cache_write: 6, - }) - }), - ) - }) - - test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, sandboxes text NOT NULL)`) - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, directory text NOT NULL, path text)`) - // Windows-shaped rows (drive + backslash) must be normalized. - yield* db.run( - sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"win"}, ${"C:\\Repo\\Thing"}, ${JSON.stringify([ - "C:\\Repo\\Thing\\sandbox", - ])})`, - ) - yield* db.run( - sql`INSERT INTO session (id, directory, path) VALUES (${"win"}, ${"C:\\Repo\\Thing\\packages\\api"}, ${"packages\\api"})`, - ) - // UNC worktrees and their sandboxes must normalize too (not just drive paths). - yield* db.run( - sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"unc"}, ${"\\\\server\\share"}, ${JSON.stringify([ - "\\\\server\\share\\sandbox", - ])})`, - ) - // The "/" worktree sentinel and POSIX paths (including a pathological - // backslash in a POSIX filename) must survive byte-for-byte. - yield* db.run(sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"global"}, ${"/"}, ${"[]"})`) - yield* db.run( - sql`INSERT INTO session (id, directory, path) VALUES (${"posix"}, ${"/home/me/we\\ird"}, ${"src\\weird"})`, - ) - - yield* DatabaseMigration.applyOnly(db, [normalizeStoragePathsMigration]) - - expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'win'`)).toEqual({ - worktree: "C:/Repo/Thing", - sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]), - }) - expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'win'`)).toEqual({ - directory: "C:/Repo/Thing/packages/api", - path: "packages/api", - }) - expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'unc'`)).toEqual({ - worktree: "//server/share", - sandboxes: JSON.stringify(["//server/share/sandbox"]), - }) - expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ worktree: "/" }) - expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'posix'`)).toEqual({ - directory: "/home/me/we\\ird", - path: "src\\weird", - }) - }), - ) - }) - - test("maps native Windows paths through database columns", async () => { - if (process.platform !== "win32") return - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* DatabaseMigration.apply(db) - const projectID = Project.ID.make("codec_project") - const worktree = AbsolutePath.make("C:\\Repo\\Thing") - const sandbox = AbsolutePath.make("C:\\Repo\\Thing\\sandbox") - const directory = "C:\\Repo\\Thing\\packages\\api" - const sessionID = SessionSchema.ID.make("ses_codec") - - expect(() => - Effect.runSync( - db - .insert(ProjectTable) - .values({ - id: Project.ID.make("invalid_path"), - worktree: AbsolutePath.make("not-absolute"), - sandboxes: [], - time_created: 1, - time_updated: 1, - }) - .run(), - ), - ).toThrow() - - yield* db - .insert(ProjectTable) - .values({ - id: projectID, - worktree, - sandboxes: [sandbox], - time_created: 1, - time_updated: 1, - }) - .run() - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: projectID, - slug: "codec", - directory, - path: "packages\\api", - title: "Codec", - version: "test", - time_created: 1, - time_updated: 1, - }) - .run() - - expect( - yield* db.get<{ worktree: string; sandboxes: string }>( - sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`, - ), - ).toEqual({ - worktree: "C:/Repo/Thing", - sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]), - }) - expect( - yield* db.get<{ directory: string; path: string }>( - sql`SELECT directory, path FROM session WHERE id = ${sessionID}`, - ), - ).toEqual({ - directory: "C:/Repo/Thing/packages/api", - path: "packages/api", - }) - - const project = yield* db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).get() - const session = yield* db.select().from(SessionTable).where(eq(SessionTable.directory, directory)).get() - expect(project?.worktree).toBe(worktree) - expect(project?.sandboxes).toEqual([sandbox]) - expect(session?.directory).toBe(directory) - expect(session?.path).toBe("packages/api") - - expect((yield* db.select().from(SessionTable).where(eq(SessionTable.path, "packages\\api")).get())?.id).toBe( - sessionID, - ) - - const moved = AbsolutePath.make("D:\\Moved\\Thing") - const updated = yield* db - .update(ProjectTable) - .set({ worktree: moved, sandboxes: [moved] }) - .where(eq(ProjectTable.id, projectID)) - .returning() - .get() - expect(updated?.worktree).toBe(moved) - expect(updated?.sandboxes).toEqual([moved]) - expect( - yield* db.get<{ worktree: string; sandboxes: string }>( - sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`, - ), - ).toEqual({ worktree: "D:/Moved/Thing", sandboxes: JSON.stringify(["D:/Moved/Thing"]) }) - expect( - (yield* db - .select() - .from(ProjectTable) - .where(inArray(ProjectTable.worktree, [moved])) - .get())?.id, - ).toBe(projectID) - - yield* db.run(sql`UPDATE project SET worktree = ${"not-absolute"} WHERE id = ${projectID}`) - expect(() => - Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()), - ).toThrow() - }), - ) - }) - - test("imports existing drizzle migration state", async () => { + test("imports an existing Drizzle migration journal once", async () => { await run( Effect.gen(function* () { const db = yield* makeDb @@ -1431,71 +228,16 @@ describe("DatabaseMigration", () => { ) yield* db.run(sql` INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) - VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()}) + VALUES ('hash', 1, 'legacy', ${new Date().toISOString()}) `) yield* DatabaseMigration.applyOnly(db, []) + expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "legacy" }]) - expect(yield* db.get(sql`SELECT id FROM migration`)).toEqual({ id: "20260127222353_familiar_lady_ursula" }) - }), - ) - }) - - test("does not replay a migrated session metadata column", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`) - yield* db.run( - sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, - ) - yield* db.run(sql` - INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) - VALUES ('hash', 1, '20260511173437_session-metadata', ${new Date().toISOString()}) - `) - - yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) - - expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }]) - }), - ) - }) - - test("accepts the temporary replacement session metadata migration id", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`) - yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`) - yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('20260530232709_lovely_romulus', 1)`) - - yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) - - expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([ - { id: "20260511173437_session-metadata" }, - { id: "20260530232709_lovely_romulus" }, - ]) - }), - ) - }) - - test("skips drizzle import when migration table already has state", async () => { - await run( - Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`) yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('existing', 1)`) - yield* db.run( - sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, - ) - yield* db.run(sql` - INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) - VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()}) - `) - + yield* db.run(sql`UPDATE __drizzle_migrations SET name = 'ignored'`) yield* DatabaseMigration.applyOnly(db, []) - - expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }]) + expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }, { id: "legacy" }]) }), ) }) diff --git a/packages/core/test/legacy-event-schema.test.ts b/packages/core/test/legacy-event-schema.test.ts deleted file mode 100644 index d9a2833b698..00000000000 --- a/packages/core/test/legacy-event-schema.test.ts +++ /dev/null @@ -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 } }) - }) -}) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index a1837f00632..68042b52f47 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -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") }), ) @@ -452,32 +451,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 @@ -485,7 +459,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) }]) }), ) @@ -503,7 +477,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 @@ -517,8 +491,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", @@ -604,7 +579,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)], ]) @@ -617,7 +592,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) }), @@ -671,7 +646,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" } }]) }), ) @@ -703,7 +678,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 }) }), diff --git a/packages/core/test/session-move.test.ts b/packages/core/test/session-move.test.ts new file mode 100644 index 00000000000..4fa80656318 --- /dev/null +++ b/packages/core/test/session-move.test.ts @@ -0,0 +1,57 @@ +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([]), + }), +) +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) + }), + ), + ), + ) +}) diff --git a/packages/core/test/v1-migration.test.ts b/packages/core/test/v1-migration.test.ts new file mode 100644 index 00000000000..457a87ddd3d --- /dev/null +++ b/packages/core/test/v1-migration.test.ts @@ -0,0 +1,1207 @@ +import { describe, expect, test } from "bun:test" +import { SqliteClient } from "@effect/sql-sqlite-bun" +import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" +import { Database } from "@opencode-ai/core/database/database" +import { DatabaseMigration } from "@opencode-ai/core/database/migration" +import { V1Migration } from "@opencode-ai/core/database/v1-migration" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionSchema } from "@opencode-ai/core/session/schema" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Global } from "@opencode-ai/util/global" +import { Effect, Layer, Logger, Schedule, Schema, Scope } from "effect" +import { eq, sql } from "drizzle-orm" +import type { SqlClient } from "effect/unstable/sql/SqlClient" +import { tmpdir } from "./fixture/tmpdir" +import path from "path" + +const makeDb = EffectDrizzleSqlite.makeWithDefaults() +const run = (effect: Effect.Effect) => + Effect.runPromise( + Effect.scoped(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })))), + ) + +const session = ( + overrides: Partial = {}, +): V1Migration.TransformInput["session"] => ({ + id: SessionSchema.ID.make("ses_test"), + project_id: Project.ID.global, + workspace_id: null, + parent_id: null, + fork_session_id: null, + fork_boundary: null, + slug: "test", + directory: "/tmp/test", + path: null, + title: "Test", + version: "1", + share_url: null, + summary_additions: null, + summary_deletions: null, + summary_files: null, + summary_diffs: null, + metadata: null, + cost: 99, + tokens_input: 99, + tokens_output: 99, + tokens_reasoning: 99, + tokens_cache_read: 99, + tokens_cache_write: 99, + revert: null, + permission: null, + agent: null, + model: null, + time_created: 1, + time_updated: 2, + time_compacting: 3, + time_archived: null, + time_suspended: null, + ...overrides, +}) + +const user = (id: string, overrides: Record = {}, time = 10): V1Migration.SourceMessage => ({ + id, + session_id: "ses_test", + time_created: time, + time_updated: time + 1, + data: JSON.stringify({ + role: "user", + time: { created: time }, + agent: "build", + model: { providerID: "provider", modelID: "model" }, + ...overrides, + }), +}) + +const assistant = ( + id: string, + parentID: string, + overrides: Record = {}, + time = 20, +): V1Migration.SourceMessage => ({ + id, + session_id: "ses_test", + time_created: time, + time_updated: time + 1, + data: JSON.stringify({ + role: "assistant", + time: { created: time, completed: time + 5 }, + parentID, + modelID: "model", + providerID: "provider", + mode: "build", + agent: "build", + path: { cwd: "/tmp/test", root: "/tmp/test" }, + cost: 1, + tokens: { total: 10, input: 2, output: 3, reasoning: 4, cache: { read: 5, write: 6 } }, + ...overrides, + }), +}) + +const part = (id: string, messageID: string, data: Record | string): V1Migration.SourcePart => ({ + id, + message_id: messageID, + session_id: "ses_test", + time_created: 1, + time_updated: 2, + data: typeof data === "string" ? data : JSON.stringify(data), +}) + +const transform = (messages: V1Migration.SourceMessage[], parts: V1Migration.SourcePart[], info = session()) => { + const result = V1Migration.transformSession({ session: info, messages, parts }) + result.messages.forEach((row) => + Schema.decodeUnknownSync(SessionMessage.Info)({ id: row.id, type: row.type, ...row.data }), + ) + return result +} + +describe("V1Migration.transformSession", () => { + test("maps ordinary user text, agents, ignored fields, order, and timestamps", () => { + const message = user("msg_000000000001aaaaaaaaaaaaaa", { + system: "discard", + tools: { read: false }, + format: { type: "text" }, + summary: { title: "discard", diffs: [] }, + }) + const result = transform( + [message], + [ + part("prt_4", message.id, { type: "agent", name: "review" }), + part("prt_2", message.id, { type: "text", text: "ignored", ignored: true }), + part("prt_3", message.id, { type: "agent", name: "build", source: { value: "@build", start: 2, end: 8 } }), + part("prt_1", message.id, { type: "text", text: "first" }), + part("prt_5", message.id, { type: "text", text: "second" }), + ], + ) + expect(result.messages).toEqual([ + { + id: message.id, + session_id: "ses_test", + type: "user", + seq: 0, + time_created: 10, + time_updated: 11, + data: { + text: "first\n\nsecond", + agents: [{ name: "build", mention: { text: "@build", start: 2, end: 8 } }, { name: "review" }], + time: { created: 10 }, + }, + }, + ]) + expect(result.watermark).toBe(0) + }) + + test("maps embedded files and deterministic placeholders without external IO", () => { + const message = user("msg_000000000002aaaaaaaaaaaaaa") + const result = transform( + [message], + [ + part("prt_1", message.id, { type: "text", text: "prompt" }), + part("prt_2", message.id, { + type: "file", + mime: "text/plain", + filename: "inline.txt", + url: "data:text/plain,hello%20world", + }), + part("prt_3", message.id, { + type: "file", + mime: "text/plain", + url: "data:text/plain;base64,aGk=", + source: { + type: "resource", + clientName: "mcp", + uri: "resource://item", + text: { value: "item", start: 1, end: 5 }, + }, + }), + part("prt_4", message.id, { + type: "file", + mime: "text/plain", + filename: "named.txt", + url: "file:///tmp/named.txt", + }), + part("prt_5", message.id, { type: "file", mime: "application/octet-stream", url: "https://example.test/raw" }), + ], + ) + expect(result.messages[0].data).toEqual({ + text: "prompt\n\n[Attachment unavailable after migration: named.txt (text/plain)]\n\n[Attachment unavailable after migration: https://example.test/raw (application/octet-stream)]", + files: [ + { data: "aGVsbG8gd29ybGQ=", mime: "text/plain", source: { type: "inline" }, name: "inline.txt" }, + { + data: "aGk=", + mime: "text/plain", + source: { type: "uri", uri: "resource://item" }, + mention: { text: "item", start: 1, end: 5 }, + }, + ], + time: { created: 10 }, + }) + }) + + test("maps attachment-only source variants and uses resource URIs as unavailable labels", () => { + const message = user("msg_000000000049aaaaaaaaaaaaaa") + const result = transform( + [message], + [ + part("prt_1", message.id, { + type: "file", + mime: "text/plain", + url: "data:text/plain,file", + source: { type: "file", path: "/tmp/a", text: { value: "a", start: 0, end: 1 } }, + }), + part("prt_2", message.id, { + type: "file", + mime: "text/plain", + url: "data:text/plain,symbol", + source: { + type: "symbol", + path: "/tmp/a", + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, + name: "a", + kind: 1, + text: { value: "symbol", start: 2, end: 8 }, + }, + }), + part("prt_3", message.id, { + type: "file", + mime: "application/json", + url: "https://example.test/resource", + source: { + type: "resource", + clientName: "mcp", + uri: "resource://fallback", + text: { value: "resource", start: 0, end: 8 }, + }, + }), + ], + ) + expect(result.messages[0].data).toEqual({ + text: "[Attachment unavailable after migration: resource://fallback (application/json)]", + files: [ + { + data: "ZmlsZQ==", + mime: "text/plain", + source: { type: "inline" }, + mention: { text: "a", start: 0, end: 1 }, + }, + { + data: "c3ltYm9s", + mime: "text/plain", + source: { type: "inline" }, + mention: { text: "symbol", start: 2, end: 8 }, + }, + ], + time: { created: 10 }, + }) + }) + + test("splits mixed synthetic content deterministically and preserves adjacency", () => { + const all = user("msg_000000000003aaaaaaaaaaaaaa", {}, 1) + const mixed = user("msg_000000000004aaaaaaaaaaaaaa", {}, 2) + const later = user("msg_000000000005aaaaaaaaaaaaaa", {}, 3) + const parts = [ + part("prt_1", all.id, { type: "text", text: "context", synthetic: true }), + part("prt_2", mixed.id, { type: "text", text: "hello" }), + part("prt_3", mixed.id, { type: "text", text: "hidden", synthetic: true }), + part("prt_4", mixed.id, { type: "text", text: "ignored", synthetic: true, ignored: true }), + part("prt_5", later.id, { type: "text", text: "later" }), + ] + const first = transform([later, mixed, all], parts) + const second = transform([later, mixed, all], parts) + expect(first.messages.map((row) => row.type)).toEqual(["synthetic", "user", "synthetic", "user"]) + expect(first.messages[0].id).toBe(all.id) + expect(first.messages[2].id).not.toBe(mixed.id) + expect(first.messages[2].id).toBe("msg_000000000004ST20Jh98kGJtjL") + expect(first.messages[2].id.slice(0, 16)).toBe(mixed.id.slice(0, 16)) + expect(first.messages[2].id).toMatch(/^msg_[0-9A-Za-z]{26}$/) + expect(first.messages[2].id).toBe(second.messages[2].id) + expect(first.messages[2].data).toEqual({ text: "hidden", time: { created: 2 } }) + const collision = user(first.messages[2].id, {}, 4) + const collided = transform( + [all, mixed, later, collision], + [...parts, part("prt_6", collision.id, { type: "text", text: "collision" })], + ) + expect(collided.messages[2].id).not.toBe(first.messages[2].id) + expect(collided.messages[2].id.slice(0, 16)).toBe(mixed.id.slice(0, 16)) + }) + + test("preserves assistant content, model, usage, finish, snapshots, and marker filtering", () => { + const parent = user("msg_000000000006aaaaaaaaaaaaaa") + const message = assistant("msg_000000000007aaaaaaaaaaaaaa", parent.id, { + variant: "fast", + structured: { discard: true }, + finish: "stop", + cost: 2.5, + }) + const result = transform( + [parent, message], + [ + part("prt_1", message.id, { type: "text", text: "", metadata: { separator: true } }), + part("prt_2", message.id, { + type: "reasoning", + text: "think", + metadata: { provider: 1 }, + time: { start: 21, end: 22 }, + }), + part("prt_3", message.id, { type: "step-start", snapshot: "snap_start" }), + part("prt_4", message.id, { type: "snapshot", snapshot: "snap_ignored" }), + part("prt_5", message.id, { type: "patch", hash: "snap_patch", files: ["a.ts", "b.ts"] }), + part("prt_6", message.id, { type: "patch", hash: "snap_patch_2", files: ["b.ts", "c.ts"] }), + part("prt_7", message.id, { + type: "step-finish", + reason: "stop", + snapshot: "snap_end", + cost: 99, + tokens: { input: 99, output: 99, reasoning: 99, cache: { read: 99, write: 99 } }, + }), + part("prt_8", message.id, { + type: "retry", + attempt: 1, + error: { name: "APIError", data: { message: "retry", isRetryable: true } }, + time: { created: 1 }, + }), + ], + ) + expect(result.messages[1].data).toEqual({ + agent: "build", + model: { id: "model", providerID: "provider", variant: "fast" }, + content: [ + { type: "text", text: "", state: { separator: true } }, + { type: "reasoning", text: "think", state: { provider: 1 }, time: { created: 21, completed: 22 } }, + ], + snapshot: { start: "snap_start", end: "snap_end", files: ["a.ts", "b.ts", "c.ts"] }, + finish: "stop", + cost: 2.5, + tokens: { input: 2, output: 3, reasoning: 4, cache: { read: 5, write: 6 } }, + time: { created: 20, completed: 21 }, + }) + expect(result.messages[1]).toMatchObject({ time_created: 20, time_updated: 21 }) + }) + + test("normalizes every tool state", () => { + const parent = user("msg_000000000008aaaaaaaaaaaaaa") + const message = assistant("msg_000000000009aaaaaaaaaaaaaa", parent.id) + const tool = (id: string, callID: string, state: Record, metadata?: Record) => + part(id, message.id, { type: "tool", callID, tool: "read", state, ...(metadata ? { metadata } : {}) }) + const result = transform( + [parent, message], + [ + tool("prt_1", "pending", { status: "pending", input: { a: 1 }, raw: "{}" }), + tool("prt_2", "running", { + status: "running", + input: { b: 2 }, + metadata: { phase: "read" }, + time: { start: 30 }, + }), + tool( + "prt_3", + "completed", + { + status: "completed", + input: { c: 3 }, + output: "done", + title: "Read", + metadata: { result: true }, + time: { start: 31, end: 32 }, + attachments: [ + { + id: "prt_attachment", + sessionID: "ses_test", + messageID: message.id, + type: "file", + mime: "text/plain", + filename: "out.txt", + url: "file:///out.txt", + }, + ], + }, + { provider: true }, + ), + tool("prt_4", "compacted", { + status: "completed", + input: {}, + output: "secret", + title: "Read", + metadata: {}, + time: { start: 33, end: 34, compacted: 35 }, + attachments: [], + }), + tool("prt_5", "failed", { + status: "error", + input: { e: 5 }, + error: "boom", + metadata: { output: "partial" }, + time: { start: 36, end: 37 }, + }), + tool("prt_6", "failed-object-output", { + status: "error", + input: { f: 6 }, + error: "object output", + metadata: { output: { nested: true } }, + time: { start: 38, end: 39 }, + }), + ], + ) + const content = result.messages[1].data.content + if (!Array.isArray(content)) throw new Error("Expected assistant content") + expect(content[0]).toMatchObject({ + id: "pending", + state: { + status: "error", + error: { type: "tool.interrupted", message: "Tool execution was interrupted before V2 migration" }, + }, + time: { created: 20 }, + }) + expect(content[1]).toMatchObject({ + id: "running", + state: { status: "error", metadata: { phase: "read" } }, + time: { created: 30 }, + }) + expect(content[2]).toMatchObject({ + id: "completed", + providerState: { provider: true }, + state: { + status: "completed", + content: [ + { type: "text", text: "done" }, + { type: "file", uri: "file:///out.txt", mime: "text/plain", name: "out.txt" }, + ], + metadata: { result: true }, + }, + time: { created: 31, completed: 32 }, + }) + expect(content[3]).toMatchObject({ + id: "compacted", + state: { content: [{ type: "text", text: "[Old tool result content cleared]" }] }, + }) + expect(content[4]).toMatchObject({ + id: "failed", + state: { + status: "error", + error: { type: "tool.execution", message: "boom" }, + content: [{ type: "text", text: "partial" }], + metadata: { output: "partial" }, + }, + time: { created: 36, completed: 37 }, + }) + expect(content[5]).toEqual({ + type: "tool", + id: "failed-object-output", + name: "read", + state: { + status: "error", + input: { f: 6 }, + error: { type: "tool.execution", message: "object output" }, + metadata: { output: { nested: true } }, + }, + time: { created: 38, completed: 39 }, + }) + }) + + test("normalizes assistant errors and finish reasons", () => { + const parent = user("msg_000000000010aaaaaaaaaaaaaa") + const cases = [ + ["ProviderAuthError", { providerID: "provider", message: "auth" }, "provider.auth", "auth"], + ["ContentFilterError", { message: "filtered" }, "provider.content-filter", "filtered"], + ["ContextOverflowError", { message: "overflow" }, "provider.invalid-request", "overflow"], + ["StructuredOutputError", { message: "shape", retries: 2 }, "provider.invalid-output", "shape"], + ["MessageOutputLengthError", {}, "provider.invalid-output", "The model exceeded its output limit"], + ["MessageAbortedError", { message: "stopped" }, "aborted", "stopped"], + [ + "APIError", + { message: "api", statusCode: 503, isRetryable: true, responseBody: "discard" }, + "provider.error", + "api", + ], + ["UnknownError", { message: "unknown", ref: "discard" }, "unknown", "unknown"], + ] as const + const messages = cases.map(([name, data], index) => + assistant( + `msg_00000000001${index}aaaaaaaaaaaaaa`, + parent.id, + { error: { name, data }, finish: index === 0 ? "new-provider-value" : undefined }, + 20 + index, + ), + ) + const result = transform([parent, ...messages], []) + cases.forEach((entry, index) => { + expect(result.messages[index + 1].data.error).toMatchObject({ type: entry[2], message: entry[3] }) + const error = result.messages[index + 1].data.error + if (!error || typeof error !== "object") throw new Error("Expected assistant error") + expect(Object.keys(error).sort()).toEqual(["message", "type"]) + }) + expect(result.messages[1].data.finish).toBe("unknown") + }) + + test("preserves every supported finish reason and omits an absent finish", () => { + const parent = user("msg_000000000050aaaaaaaaaaaaaa") + const finishes = ["stop", "length", "tool-calls", "content-filter", "error", "unknown", undefined] as const + const result = transform( + [ + parent, + ...finishes.map((finish, index) => + assistant(`msg_00000000005${index + 1}aaaaaaaaaaaaaa`, parent.id, finish ? { finish } : {}, 20 + index), + ), + ], + [], + ) + expect(result.messages.slice(1).map((row) => row.data.finish)).toEqual([...finishes]) + }) + + test("filters subtasks, collapses compactions, and keeps contiguous sequences", () => { + const subtask = user("msg_000000000020aaaaaaaaaaaaaa", {}, 1) + const taskAssistant = assistant("msg_000000000021aaaaaaaaaaaaaa", subtask.id, {}, 2) + const compact = user("msg_000000000022aaaaaaaaaaaaaa", {}, 3) + const unrelated = assistant("msg_000000000023aaaaaaaaaaaaaa", subtask.id, {}, 4) + const summary = assistant("msg_000000000024aaaaaaaaaaaaaa", compact.id, { summary: true }, 5) + const result = transform( + [summary, compact, unrelated, taskAssistant, subtask], + [ + part("prt_1", subtask.id, { type: "subtask", prompt: "work", description: "work", agent: "build" }), + part("prt_2", taskAssistant.id, { + type: "tool", + callID: "task", + tool: "task", + state: { + status: "completed", + input: {}, + output: "done", + title: "Task", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }), + part("prt_3", unrelated.id, { type: "text", text: "keep" }), + part("prt_4", compact.id, { type: "compaction", auto: false }), + part("prt_5", summary.id, { type: "text", text: "summary" }), + part("prt_6", summary.id, { type: "text", text: "" }), + ], + ) + expect(result.messages.map((row) => [row.type, row.seq])).toEqual([ + ["compaction", 0], + ["assistant", 1], + ]) + expect(result.messages[0]).toMatchObject({ + id: compact.id, + time_created: 3, + time_updated: 6, + data: { status: "completed", reason: "manual", summary: "summary", recent: "" }, + }) + }) + + test("serializes the retained compaction tail and preserves existing session selections", () => { + const tailUser = user("msg_000000000025aaaaaaaaaaaaaa", {}, 1) + const tailAssistant = assistant("msg_000000000026aaaaaaaaaaaaaa", tailUser.id, {}, 2) + const compact = user("msg_000000000027aaaaaaaaaaaaaa", {}, 3) + const summary = assistant("msg_000000000028aaaaaaaaaaaaaa", compact.id, { summary: true }, 4) + const existing = session({ + agent: "existing", + model: { id: "existing-model", providerID: "existing-provider", variant: "existing" }, + }) + const result = transform( + [summary, compact, tailAssistant, tailUser], + [ + part("prt_1", tailUser.id, { type: "text", text: "question" }), + part("prt_2", tailAssistant.id, { type: "text", text: "answer" }), + part("prt_3", compact.id, { type: "compaction", auto: true, tail_start_id: tailUser.id }), + part("prt_4", summary.id, { type: "text", text: "summary" }), + ], + existing, + ) + expect(result.messages[2].data).toMatchObject({ + reason: "auto", + summary: "summary", + recent: "[User]: question\n\n[Assistant]: answer", + }) + expect(result.session.agent).toBe("existing") + expect(result.session.model).toEqual(existing.model) + }) + + test("skips malformed rows, reports exact identifiers, and still backfills session aggregates", () => { + const good = user( + "msg_000000000030aaaaaaaaaaaaaa", + { agent: "review", model: { providerID: "p2", modelID: "m2" } }, + 2, + ) + const badJson = { ...user("msg_000000000031aaaaaaaaaaaaaa"), data: "{" } + const badSchema = { ...user("msg_000000000032aaaaaaaaaaaaaa"), data: JSON.stringify({ role: "user" }) } + const internal = assistant( + "msg_000000000033aaaaaaaaaaaaaa", + good.id, + { cost: 7, tokens: { input: 8, output: 9, reasoning: 10, cache: { read: 11, write: 12 } } }, + 3, + ) + const source = [good, badJson, badSchema, internal] + const result = transform(source, [ + part("prt_1", good.id, { type: "text", text: "before" }), + part("prt_2", good.id, "{"), + part("prt_3", good.id, { type: "future", value: true }), + part("prt_4", "msg_missing", { type: "text", text: "orphan" }), + part("prt_5", good.id, { type: "text", text: "after" }), + ]) + expect(result.messages[0].data.text).toBe("before\n\nafter") + expect(result.messages.map((row) => row.seq)).toEqual([0, 1]) + expect(result.warnings).toEqual([ + { reason: "invalid-message", sessionID: "ses_test", messageID: badJson.id }, + { reason: "invalid-message", sessionID: "ses_test", messageID: badSchema.id }, + { reason: "invalid-part", sessionID: "ses_test", messageID: good.id, partID: "prt_2", observedType: undefined }, + { reason: "invalid-part", sessionID: "ses_test", messageID: good.id, partID: "prt_3", observedType: "future" }, + { + reason: "orphan-part", + sessionID: "ses_test", + messageID: "msg_missing", + partID: "prt_4", + observedType: "text", + }, + ]) + expect(result.session).toEqual({ + agent: "review", + model: { id: "m2", providerID: "p2", variant: "default" }, + cost: 7, + tokens_input: 8, + tokens_output: 9, + tokens_reasoning: 10, + tokens_cache_read: 11, + tokens_cache_write: 12, + revert: null, + time_compacting: null, + }) + expect(source[0]).toBe(good) + }) + + test("retains empty ordinary messages and omits failed compactions", () => { + const empty = user("msg_000000000034aaaaaaaaaaaaaa", {}, 1) + const assistantMessage = assistant("msg_000000000035aaaaaaaaaaaaaa", empty.id, {}, 2) + const compact = user("msg_000000000036aaaaaaaaaaaaaa", {}, 3) + const failed = assistant( + "msg_000000000037aaaaaaaaaaaaaa", + compact.id, + { summary: true, error: { name: "UnknownError", data: { message: "failed" } } }, + 4, + ) + const result = transform( + [empty, assistantMessage, compact, failed], + [ + part("prt_1", empty.id, { type: "text", text: "ignored", ignored: true }), + part("prt_2", assistantMessage.id, { type: "snapshot", snapshot: "standalone" }), + part("prt_3", compact.id, { type: "compaction", auto: true }), + part("prt_4", failed.id, { type: "text", text: "not committed" }), + ], + ) + expect(result.messages.map((row) => row.type)).toEqual(["user", "assistant"]) + expect(result.messages[0].data).toEqual({ text: "", time: { created: 1 } }) + expect(result.messages[1].data).toMatchObject({ content: [], snapshot: { start: "standalone" } }) + expect(result.watermark).toBe(1) + }) + + test("orders equal-time rows by ID and keeps SQL and payload timestamps consistent", () => { + const first = user("msg_000000000041aaaaaaaaaaaaaa", { time: { created: 999 } }, 10) + const second = assistant("msg_000000000042aaaaaaaaaaaaaa", first.id, { time: { created: 998, completed: 997 } }, 10) + const result = transform( + [second, first], + [ + part("prt_1", first.id, { type: "text", text: "first" }), + part("prt_2", second.id, { type: "text", text: "second" }), + ], + ) + expect(result.messages.map((row) => row.id)).toEqual([first.id, second.id]) + expect(result.messages.map((row) => [row.time_created, row.time_updated, row.data.time])).toEqual([ + [10, 11, { created: 10 }], + [10, 11, { created: 10, completed: 11 }], + ]) + }) + + test("omits incomplete compactions and subtask assistants while retaining their aggregate usage", () => { + const mixed = user("msg_000000000043aaaaaaaaaaaaaa", {}, 1) + const task = assistant( + "msg_000000000044aaaaaaaaaaaaaa", + mixed.id, + { cost: 4, tokens: { input: 5, output: 6, reasoning: 7, cache: { read: 8, write: 9 } } }, + 2, + ) + const compact = user("msg_000000000045aaaaaaaaaaaaaa", {}, 3) + const unfinished = assistant( + "msg_000000000046aaaaaaaaaaaaaa", + compact.id, + { summary: true, time: { created: 4 } }, + 4, + ) + const result = transform( + [unfinished, compact, task, mixed], + [ + part("prt_1", mixed.id, { type: "text", text: "keep" }), + part("prt_2", mixed.id, { type: "subtask", prompt: "work", description: "work", agent: "build" }), + part("prt_3", task.id, { + type: "tool", + callID: "task", + tool: "task", + state: { status: "pending", input: {}, raw: "{}" }, + }), + part("prt_4", compact.id, { type: "compaction", auto: true }), + part("prt_5", unfinished.id, { type: "text", text: "unfinished" }), + ], + ) + expect(result.messages.map((row) => [row.id, row.type])).toEqual([[mixed.id, "user"]]) + expect(result.watermark).toBe(0) + expect(result.session).toMatchObject({ + cost: 5, + tokens_input: 7, + tokens_output: 9, + tokens_reasoning: 11, + tokens_cache_read: 13, + tokens_cache_write: 15, + }) + }) +}) + +describe("V1Migration database workflow", () => { + const createLegacyTables = Effect.fnUntraced(function* (db: Effect.Success) { + yield* db.run(sql` + CREATE TABLE session ( + id text PRIMARY KEY, + project_id text NOT NULL, + workspace_id text, + parent_id text, + slug text NOT NULL, + directory text NOT NULL, + path text, + title text NOT NULL, + 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 + ) + `) + yield* db.run(sql` + 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 + ) + `) + yield* db.run(sql` + 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 + ) + `) + }) + + const database = (effect: Effect.Effect) => + run( + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + yield* createLegacyTables(db) + return yield* effect.pipe(Effect.provideService(Database.Service, { db })) + }), + ) + + test("reports required and completed status and completes an empty database idempotently", async () => { + await database( + Effect.gen(function* () { + expect(yield* V1Migration.status()).toEqual({ status: "required" }) + expect(yield* V1Migration.run()).toEqual({ status: "completed" }) + expect(yield* V1Migration.status()).toEqual({ status: "completed" }) + expect(yield* V1Migration.run()).toEqual({ status: "completed" }) + }), + ) + }) + + test("imports previous V2 sessions and messages as part of the migration", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "opencode-next.db") + const sqlite = await import("bun:sqlite") + const source = new sqlite.Database(filename) + source.run(` + CREATE TABLE project ( + id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text, icon_url text, icon_url_override text, + icon_color text, time_created integer NOT NULL, time_updated integer NOT NULL, time_initialized integer, + sandboxes text NOT NULL, commands text + ); + CREATE TABLE session ( + 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 + ); + CREATE TABLE 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 + ); + INSERT INTO project VALUES ( + 'next-project', 'C:/Users/sewer', 'git', 'Source project', NULL, NULL, NULL, 1, 2, NULL, '[]', NULL + ); + INSERT INTO session ( + id, project_id, slug, directory, title, version, agent, model, time_created, time_updated + ) VALUES + ('ses_next', 'next-project', 'next', 'C:/Users/sewer', 'Imported', '2', 'build', + '{"id":"model","providerID":"provider"}', 10, 20), + ('ses_existing', 'next-project', 'source-existing', '/tmp/next', 'Source existing', '2', NULL, NULL, 11, 21), + ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '2', NULL, NULL, 12, 22); + INSERT INTO session_message VALUES + ('msg_next', 'ses_next', 'user', 4, 12, 13, '{"text":"from next","time":{"created":12}}'), + ('msg_source_existing', 'ses_existing', 'user', 2, 12, 13, '{"text":"source","time":{"created":12}}'), + ('msg_orphan', 'ses_orphan', 'user', 0, 12, 13, '{"text":"orphan","time":{"created":12}}'); + `) + source.close() + + await database( + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.run(sql` + INSERT INTO project (id, worktree, name, time_created, time_updated, sandboxes) + VALUES ('next-project', '/tmp/current', 'Current project', 1, 2, '[]') + `) + yield* db.run(sql` + INSERT INTO session_v2 (id, project_id, slug, directory, title, version, time_created, time_updated) + VALUES ('ses_existing', 'next-project', 'current-existing', '/tmp/current', 'Current existing', '2', 1, 2) + `) + yield* db.run(sql` + INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) + VALUES ('msg_current_existing', 'ses_existing', 'user', 0, 1, 2, '{"text":"current","time":{"created":1}}') + `) + + expect(yield* V1Migration.status()).toEqual({ + status: "required", + }) + expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" }) + expect(yield* V1Migration.status()).toEqual({ + status: "completed", + }) + expect(yield* db.get(sql`SELECT title, agent, model FROM session_v2 WHERE id = 'ses_next'`)).toEqual({ + title: "Imported", + agent: "build", + model: '{"id":"model","providerID":"provider"}', + }) + expect( + yield* db + .select({ directory: SessionTable.directory }) + .from(SessionTable) + .where(eq(SessionTable.id, SessionSchema.ID.make("ses_next"))) + .get(), + ).toEqual({ directory: process.platform === "win32" ? "C:\\Users\\sewer" : "C:/Users/sewer" }) + expect(yield* db.all(sql`SELECT id, seq, data FROM session_message WHERE session_id = 'ses_next'`)).toEqual([ + { + id: "msg_next", + seq: 4, + data: '{"text":"from next","time":{"created":12}}', + }, + ]) + expect(yield* db.get(sql`SELECT seq, owner_id FROM event_sequence WHERE aggregate_id = 'ses_next'`)).toEqual({ + seq: 4, + owner_id: null, + }) + expect(yield* db.get(sql`SELECT title FROM session_v2 WHERE id = 'ses_existing'`)).toEqual({ + title: "Current existing", + }) + expect(yield* db.all(sql`SELECT id FROM session_message WHERE session_id = 'ses_existing'`)).toEqual([ + { id: "msg_current_existing" }, + ]) + expect(yield* db.get(sql`SELECT project_id FROM session_v2 WHERE id = 'ses_orphan'`)).toEqual({ + project_id: "global", + }) + expect(yield* db.get(sql`SELECT name, worktree FROM project WHERE id = 'next-project'`)).toEqual({ + name: "Current project", + worktree: "/tmp/current", + }) + yield* db.run(sql`UPDATE project SET worktree = 'C:/Users/sewer' WHERE id = 'next-project'`) + expect( + yield* db + .select({ worktree: ProjectTable.worktree }) + .from(ProjectTable) + .where(eq(ProjectTable.id, Project.ID.make("next-project"))) + .get(), + ).toEqual({ + worktree: AbsolutePath.make(process.platform === "win32" ? "C:\\Users\\sewer" : "C:/Users/sewer"), + }) + expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({ + value: '{"phase":"completed"}', + }) + }), + ) + }) + + test("derives required status from the durable cursor", async () => { + await database( + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.run( + sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/tmp/test', 1, 2, '[]')`, + ) + yield* Effect.forEach(["ses_c", "ses_a", "ses_b"], (id) => + db.run( + sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES (${id}, 'global', ${id}, '/tmp/test', 'Test', '1', 1, 2)`, + ), + ) + yield* db.run( + sql`INSERT INTO kv (key, value, time_created, time_updated) VALUES ('migration.v1-v2', '{"phase":"sessions","cursor":"ses_b"}', 1, 1)`, + ) + expect(yield* V1Migration.status()).toEqual({ status: "required" }) + }), + ) + }) + + test("reassigns V1 sessions whose projects are missing to the global project", async () => { + await database( + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.run( + sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '1', 1, 2)`, + ) + + expect(yield* V1Migration.run()).toEqual({ status: "completed" }) + expect(yield* db.get(sql`SELECT project_id FROM session_v2 WHERE id = 'ses_orphan'`)).toEqual({ + project_id: "global", + }) + expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ + worktree: path.parse(Global.Path.data).root, + }) + expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({ + value: '{"phase":"completed"}', + }) + }), + ) + }) + + test("replaces projections, updates sessions, preserves V1 rows, and checkpoints completion", async () => { + await database( + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.run( + sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/tmp/test', 1, 2, '[]')`, + ) + yield* db.run(sql`INSERT INTO session ( + id, project_id, slug, directory, title, version, cost, tokens_input, tokens_output, tokens_reasoning, + tokens_cache_read, tokens_cache_write, revert, agent, model, metadata, time_created, time_updated, + time_compacting, time_archived + ) VALUES ( + 'ses_test', 'global', 'test', '/tmp/test', 'Test', '1', 99, 99, 99, 99, 99, 99, '{}', 'preserved', + '{"id":"selected","providerID":"selected-provider","variant":"selected-variant"}', '{"keep":true}', + 1, 2, 3, 4 + )`) + const source = user("msg_000000000040aaaaaaaaaaaaaa") + const sourcePart = part("prt_1", source.id, { type: "text", text: "hello" }) + yield* db.run( + sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (${source.id}, 'ses_test', 10, 11, ${source.data})`, + ) + yield* db.run( + sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('prt_1', ${source.id}, 'ses_test', 1, 2, ${sourcePart.data})`, + ) + yield* db.run( + sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_stale', 'ses_test', 'user', 0, 1, 1, '{"text":"stale","time":{"created":1}}')`, + ) + yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('ses_test', 9)`) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('event_stale', 'ses_test', 9, 1, 'session.renamed.1', '{}')`, + ) + expect(yield* V1Migration.run()).toEqual({ status: "completed" }) + expect(yield* db.all(sql`SELECT id, type, seq, time_created, time_updated, data FROM session_message`)).toEqual( + [ + { + id: source.id, + type: "user", + seq: 0, + time_created: 10, + time_updated: 11, + data: '{"text":"hello","time":{"created":10}}', + }, + ], + ) + expect(yield* db.all(sql`SELECT id, data FROM message`)).toEqual([{ id: source.id, data: source.data }]) + expect(yield* db.all(sql`SELECT id, data FROM part`)).toEqual([{ id: "prt_1", data: sourcePart.data }]) + expect(yield* db.get(sql`SELECT seq FROM event_sequence WHERE aggregate_id = 'ses_test'`)).toEqual({ seq: 0 }) + expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([]) + expect( + yield* db.get( + sql`SELECT agent, model, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, revert, time_created, time_updated, time_compacting, time_archived FROM session_v2 WHERE id = 'ses_test'`, + ), + ).toEqual({ + agent: "preserved", + model: '{"id":"selected","providerID":"selected-provider","variant":"selected-variant"}', + metadata: '{"keep":true}', + cost: 0, + tokens_input: 0, + tokens_output: 0, + tokens_reasoning: 0, + tokens_cache_read: 0, + tokens_cache_write: 0, + revert: null, + time_created: 1, + time_updated: 2, + time_compacting: null, + time_archived: 4, + }) + expect(yield* db.get(sql`SELECT cost, revert, time_compacting FROM session WHERE id = 'ses_test'`)).toEqual({ + cost: 99, + revert: "{}", + time_compacting: 3, + }) + expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({ + value: '{"phase":"completed"}', + }) + }), + ) + }) + + test("rolls back one session atomically and resumes from the committed cursor", async () => { + await database( + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.run( + sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/tmp/test', 1, 2, '[]')`, + ) + yield* Effect.forEach(["ses_a", "ses_b", "ses_c"], (id) => + db.run( + sql`INSERT INTO session (id, project_id, slug, directory, title, version, cost, time_created, time_updated) VALUES (${id}, 'global', ${id}, '/tmp/test', 'Test', '1', 99, 1, 2)`, + ), + ) + yield* db.run( + sql`CREATE TRIGGER fail_b BEFORE UPDATE ON session_v2 WHEN NEW.id = 'ses_b' BEGIN SELECT RAISE(ABORT, 'stop'); END`, + ) + yield* db.run( + sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_stale_b', 'ses_b', 'user', 0, 7, 8, '{"text":"stale","time":{"created":7}}')`, + ) + yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq, owner_id) VALUES ('ses_b', 7, 'owner')`) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('event_stale_b', 'ses_b', 7, 1, 'session.renamed.1', '{}')`, + ) + yield* Layer.launch(V1Migration.layer).pipe(Effect.forkScoped) + const failed = yield* V1Migration.status().pipe( + Effect.filterOrFail((status) => status.status === "error"), + Effect.retry(Schedule.spaced("10 millis")), + ) + expect(failed.status).toBe("error") + if (failed.status === "error") expect(failed.error).toContain("stop") + expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({ + value: '{"phase":"sessions","cursor":"ses_c"}', + }) + expect(yield* db.get(sql`SELECT cost FROM session_v2 WHERE id = 'ses_c'`)).toEqual({ cost: 0 }) + expect(yield* db.get(sql`SELECT cost FROM session_v2 WHERE id = 'ses_b'`)).toBeUndefined() + expect(yield* db.get(sql`SELECT cost FROM session WHERE id = 'ses_b'`)).toEqual({ cost: 99 }) + expect( + yield* db.all( + sql`SELECT id, seq, time_created, time_updated, data FROM session_message WHERE session_id = 'ses_b'`, + ), + ).toEqual([ + { + id: "msg_stale_b", + seq: 0, + time_created: 7, + time_updated: 8, + data: '{"text":"stale","time":{"created":7}}', + }, + ]) + expect(yield* db.get(sql`SELECT seq, owner_id FROM event_sequence WHERE aggregate_id = 'ses_b'`)).toEqual({ + seq: 7, + owner_id: "owner", + }) + expect(yield* db.all(sql`SELECT id FROM event WHERE aggregate_id = 'ses_b'`)).toEqual([]) + expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({ + value: '{"phase":"sessions","cursor":"ses_c"}', + }) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('event_after_clear', 'ses_c', 0, 2, 'session.renamed.1', '{}')`, + ) + yield* db.run(sql`DROP TRIGGER fail_b`) + yield* Layer.launch(V1Migration.layer).pipe(Effect.forkScoped) + yield* V1Migration.status().pipe( + Effect.filterOrFail((status) => status.status === "completed"), + Effect.retry(Schedule.spaced("10 millis")), + ) + expect(yield* db.get(sql`SELECT cost FROM session_v2 WHERE id = 'ses_b'`)).toEqual({ cost: 0 }) + expect(yield* db.all(sql`SELECT id FROM session_message WHERE session_id = 'ses_b'`)).toEqual([]) + expect(yield* db.get(sql`SELECT seq, owner_id FROM event_sequence WHERE aggregate_id = 'ses_b'`)).toEqual({ + seq: -1, + owner_id: null, + }) + expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([{ id: "event_after_clear" }]) + }), + ) + }) + + test("processes root, child, archived, empty, malformed-only, subtask-only, and incomplete-compaction sessions", async () => { + const output = new Array>() + await database( + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.run( + sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/tmp/test', 1, 2, '[]')`, + ) + yield* db.run( + sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_root', 'global', 'root', '/tmp/test', 'Root', '1', 1, 2)`, + ) + yield* db.run( + sql`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_child', 'global', 'ses_root', 'child', '/tmp/test', 'Child', '1', 1, 2)`, + ) + yield* db.run( + sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES ('ses_archived', 'global', 'archived', '/tmp/test', 'Archived', '1', 1, 2, 3)`, + ) + yield* Effect.forEach(["ses_empty", "ses_malformed", "ses_subtask", "ses_compaction"], (id) => + db.run( + sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES (${id}, 'global', ${id}, '/tmp/test', ${id}, '1', 1, 2)`, + ), + ) + yield* db.run( + sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('msg_bad', 'ses_malformed', 1, 2, '{')`, + ) + const subtask = user("msg_000000000047aaaaaaaaaaaaaa") + yield* db.run( + sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (${subtask.id}, 'ses_subtask', 10, 11, ${subtask.data})`, + ) + yield* db.run( + sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('prt_subtask', ${subtask.id}, 'ses_subtask', 1, 2, '{"type":"subtask","prompt":"work","description":"work","agent":"build"}')`, + ) + const compact = user("msg_000000000048aaaaaaaaaaaaaa") + yield* db.run( + sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (${compact.id}, 'ses_compaction', 10, 11, ${compact.data})`, + ) + yield* db.run( + sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('prt_compaction', ${compact.id}, 'ses_compaction', 1, 2, '{"type":"compaction","auto":true}')`, + ) + + expect(yield* V1Migration.run()).toEqual({ status: "completed" }) + expect(yield* db.all(sql`SELECT aggregate_id, seq FROM event_sequence ORDER BY aggregate_id`)).toEqual([ + { aggregate_id: "ses_archived", seq: -1 }, + { aggregate_id: "ses_child", seq: -1 }, + { aggregate_id: "ses_compaction", seq: -1 }, + { aggregate_id: "ses_empty", seq: -1 }, + { aggregate_id: "ses_malformed", seq: -1 }, + { aggregate_id: "ses_root", seq: -1 }, + { aggregate_id: "ses_subtask", seq: -1 }, + ]) + expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([]) + expect(yield* V1Migration.status()).toEqual({ status: "completed" }) + expect(output.map((entry) => entry.message)).toContainEqual([ + "Skipped V1 migration row", + { + reason: "invalid-message", + sessionID: "ses_malformed", + messageID: "msg_bad", + }, + ]) + }).pipe( + Effect.provide( + Logger.layer([ + Logger.map(Logger.formatStructured, (entry) => { + output.push(entry) + }), + ]), + ), + ), + ) + }) + + test("serializes concurrent callers and migrates each session once", async () => { + await database( + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.run( + sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/tmp/test', 1, 2, '[]')`, + ) + yield* db.run( + sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_test', 'global', 'test', '/tmp/test', 'Test', '1', 1, 2)`, + ) + yield* db.run(sql`CREATE TABLE audit (count integer NOT NULL)`) + yield* db.run(sql`INSERT INTO audit VALUES (0)`) + yield* db.run( + sql`CREATE TRIGGER audit_session AFTER UPDATE ON session_v2 BEGIN UPDATE audit SET count = count + 1; END`, + ) + expect(yield* Effect.all([V1Migration.run(), V1Migration.run()], { concurrency: "unbounded" })).toEqual([ + { status: "completed" }, + { status: "completed" }, + ]) + expect(yield* db.get(sql`SELECT count FROM audit`)).toEqual({ count: 1 }) + }), + ) + }) +}) diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 66907577a98..b33dfeb13be 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -11757,6 +11757,136 @@ "summary": "Evict a loaded location" } }, + "/api/experimental/migration/v1": { + "get": { + "tags": [ + "migration" + ], + "operationId": "v2.experimental.migration.v1.status", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "required", + "running", + "completed" + ] + }, + "completed": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "total": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "status", + "completed", + "total" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Return the progress of the V1 to V2 session history migration.", + "summary": "Get V1 migration status" + }, + "post": { + "tags": [ + "migration" + ], + "operationId": "v2.experimental.migration.v1.run", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "completed" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Run or resume the V1 to V2 session history migration and wait for completion.", + "summary": "Run V1 migration" + } + }, "/api/websearch/provider": { "get": { "tags": [ @@ -14518,6 +14648,123 @@ ], "additionalProperties": false }, + "session.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.created" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "projectID": { + "type": "string" + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "subpath": { + "type": "string" + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "version": { + "type": "string" + } + }, + "required": [ + "sessionID", + "projectID", + "location", + "slug", + "version" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, "session.agent.selected": { "type": "object", "properties": { @@ -17072,7 +17319,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "name": { @@ -17082,7 +17329,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "name" ], "additionalProperties": false @@ -17170,7 +17417,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "text": { @@ -17180,7 +17427,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "text" ], "additionalProperties": false @@ -17271,7 +17518,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "input": { @@ -17287,7 +17534,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "input", "executed" ], @@ -17422,7 +17669,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "content": { @@ -17450,7 +17697,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "content", "executed" ], @@ -17542,7 +17789,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "error": { @@ -17573,7 +17820,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "error", "executed" ], @@ -18445,6 +18692,9 @@ }, "Session.Event.Durable": { "oneOf": [ + { + "$ref": "#/components/schemas/session.created" + }, { "$ref": "#/components/schemas/session.agent.selected" }, @@ -21855,14 +22105,14 @@ "messageID": { "type": "string" }, - "callID": { + "id": { "type": "string" } }, "required": [ "type", "messageID", - "callID" + "id" ], "additionalProperties": false } @@ -22280,3069 +22530,6 @@ ], "additionalProperties": false }, - "FileDiff.LegacyInfo": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "patch": { - "type": "string" - }, - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "status": { - "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] - } - }, - "required": [ - "additions", - "deletions" - ], - "additionalProperties": false - }, - "PermissionV1.Action": { - "type": "string", - "enum": [ - "allow", - "deny", - "ask" - ] - }, - "PermissionV1.Rule": { - "type": "object", - "properties": { - "permission": { - "type": "string" - }, - "pattern": { - "type": "string" - }, - "action": { - "$ref": "#/components/schemas/PermissionV1.Action" - } - }, - "required": [ - "permission", - "pattern", - "action" - ], - "additionalProperties": false - }, - "PermissionV1.Ruleset": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PermissionV1.Rule" - } - }, - "SessionV1.Info": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" - }, - "workspaceID": { - "type": "string", - "allOf": [ - { - "pattern": "^wrk" - } - ] - }, - "directory": { - "type": "string" - }, - "path": { - "type": "string" - }, - "parentID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "summary": { - "type": "object", - "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff.LegacyInfo" - } - } - }, - "required": [ - "additions", - "deletions", - "files" - ], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false - }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": [ - "url" - ], - "additionalProperties": false - }, - "title": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": [ - "id", - "providerID" - ], - "additionalProperties": false - }, - "version": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "updated": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "compacting": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "archived": { - "type": "number" - } - }, - "required": [ - "created", - "updated" - ], - "additionalProperties": false - }, - "permission": { - "$ref": "#/components/schemas/PermissionV1.Ruleset" - }, - "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "partID": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": [ - "messageID" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "slug", - "projectID", - "directory", - "version", - "time" - ], - "additionalProperties": false - }, - "session.created": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.created" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "info": { - "$ref": "#/components/schemas/SessionV1.Info" - } - }, - "required": [ - "sessionID", - "info" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "session.updated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.updated" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "info": { - "$ref": "#/components/schemas/SessionV1.Info" - } - }, - "required": [ - "sessionID", - "info" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "session.deleted1": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.deleted" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "info": { - "$ref": "#/components/schemas/SessionV1.Info" - } - }, - "required": [ - "sessionID", - "info" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "SessionV1.JSONSchema": { - "type": "object" - }, - "SessionV1.OutputFormat": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ] - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "json_schema" - ] - }, - "schema": { - "$ref": "#/components/schemas/SessionV1.JSONSchema" - }, - "retryCount": { - "anyOf": [ - { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "type", - "schema" - ], - "additionalProperties": false - } - ] - }, - "SessionV1.UserMessage": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "role": { - "type": "string", - "enum": [ - "user" - ] - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "number", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "created" - ], - "additionalProperties": false - }, - "format": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.OutputFormat" - }, - { - "type": "null" - } - ] - }, - "summary": { - "anyOf": [ - { - "type": "object", - "properties": { - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "body": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff.LegacyInfo" - } - } - }, - "required": [ - "diffs" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - }, - "variant": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "providerID", - "modelID" - ], - "additionalProperties": false - }, - "system": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "type": "boolean" - } - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "role", - "time", - "agent", - "model" - ], - "additionalProperties": false - }, - "ProviderAuthError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "ProviderAuthError" - ] - }, - "data": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": [ - "providerID", - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "UnknownError1": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "UnknownError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "ref": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "MessageOutputLengthError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "MessageOutputLengthError" - ] - }, - "data": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "array" - } - ] - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "MessageAbortedError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "MessageAbortedError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "StructuredOutputError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "StructuredOutputError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "retries": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "message", - "retries" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "ContextOverflowError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "ContextOverflowError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "responseBody": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "ContentFilterError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "ContentFilterError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "APIError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "APIError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "statusCode": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - }, - "isRetryable": { - "type": "boolean" - }, - "responseHeaders": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ] - }, - "responseBody": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "metadata": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "message", - "isRetryable" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "SessionV1.AssistantMessage": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "role": { - "type": "string", - "enum": [ - "assistant" - ] - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "completed": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "created" - ], - "additionalProperties": false - }, - "error": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/components/schemas/ProviderAuthError" - }, - { - "$ref": "#/components/schemas/UnknownError1" - }, - { - "$ref": "#/components/schemas/MessageOutputLengthError" - }, - { - "$ref": "#/components/schemas/MessageAbortedError" - }, - { - "$ref": "#/components/schemas/StructuredOutputError" - }, - { - "$ref": "#/components/schemas/ContextOverflowError" - }, - { - "$ref": "#/components/schemas/ContentFilterError" - }, - { - "$ref": "#/components/schemas/APIError" - } - ] - }, - { - "type": "null" - } - ] - }, - "parentID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "modelID": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "mode": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "path": { - "type": "object", - "properties": { - "cwd": { - "type": "string" - }, - "root": { - "type": "string" - } - }, - "required": [ - "cwd", - "root" - ], - "additionalProperties": false - }, - "summary": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "total": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false - }, - "structured": { - "anyOf": [ - {}, - { - "type": "null" - } - ] - }, - "variant": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "finish": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "role", - "time", - "parentID", - "modelID", - "providerID", - "mode", - "agent", - "path", - "cost", - "tokens" - ], - "additionalProperties": false - }, - "SessionV1.Message": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.UserMessage" - }, - { - "$ref": "#/components/schemas/SessionV1.AssistantMessage" - } - ] - }, - "message.updated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "message.updated" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "info": { - "$ref": "#/components/schemas/SessionV1.Message" - } - }, - "required": [ - "sessionID", - "info" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "message.removed": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "message.removed" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - } - }, - "required": [ - "sessionID", - "messageID" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "SessionV1.TextPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "text" - ] - }, - "text": { - "type": "string" - }, - "synthetic": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "ignored": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "time": { - "anyOf": [ - { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "start" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text" - ], - "additionalProperties": false - }, - "SessionV1.SubtaskPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "subtask" - ] - }, - "prompt": { - "type": "string" - }, - "description": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "anyOf": [ - { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": [ - "providerID", - "modelID" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "command": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "prompt", - "description", - "agent" - ], - "additionalProperties": false - }, - "SessionV1.ReasoningPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "reasoning" - ] - }, - "text": { - "type": "string" - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "start" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text", - "time" - ], - "additionalProperties": false - }, - "SessionV1.FilePartSourceText": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "start": { - "type": "number" - }, - "end": { - "type": "number" - } - }, - "required": [ - "value", - "start", - "end" - ], - "additionalProperties": false - }, - "SessionV1.FileSource": { - "type": "object", - "properties": { - "text": { - "$ref": "#/components/schemas/SessionV1.FilePartSourceText" - }, - "type": { - "type": "string", - "enum": [ - "file" - ] - }, - "path": { - "type": "string" - } - }, - "required": [ - "text", - "type", - "path" - ], - "additionalProperties": false - }, - "SessionV1.Range": { - "type": "object", - "properties": { - "start": { - "type": "object", - "properties": { - "line": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "character": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "line", - "character" - ], - "additionalProperties": false - }, - "end": { - "type": "object", - "properties": { - "line": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "character": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "line", - "character" - ], - "additionalProperties": false - } - }, - "required": [ - "start", - "end" - ], - "additionalProperties": false - }, - "SessionV1.SymbolSource": { - "type": "object", - "properties": { - "text": { - "$ref": "#/components/schemas/SessionV1.FilePartSourceText" - }, - "type": { - "type": "string", - "enum": [ - "symbol" - ] - }, - "path": { - "type": "string" - }, - "range": { - "$ref": "#/components/schemas/SessionV1.Range" - }, - "name": { - "type": "string" - }, - "kind": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "text", - "type", - "path", - "range", - "name", - "kind" - ], - "additionalProperties": false - }, - "SessionV1.ResourceSource": { - "type": "object", - "properties": { - "text": { - "$ref": "#/components/schemas/SessionV1.FilePartSourceText" - }, - "type": { - "type": "string", - "enum": [ - "resource" - ] - }, - "clientName": { - "type": "string" - }, - "uri": { - "type": "string" - } - }, - "required": [ - "text", - "type", - "clientName", - "uri" - ], - "additionalProperties": false - }, - "SessionV1.FilePartSource": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.FileSource" - }, - { - "$ref": "#/components/schemas/SessionV1.SymbolSource" - }, - { - "$ref": "#/components/schemas/SessionV1.ResourceSource" - } - ] - }, - "SessionV1.FilePart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "file" - ] - }, - "mime": { - "type": "string" - }, - "filename": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "url": { - "type": "string" - }, - "source": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.FilePartSource" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "mime", - "url" - ], - "additionalProperties": false - }, - "SessionV1.ToolStatePending": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "pending" - ] - }, - "input": { - "type": "object" - }, - "raw": { - "type": "string" - } - }, - "required": [ - "status", - "input", - "raw" - ], - "additionalProperties": false - }, - "SessionV1.ToolStateRunning": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "running" - ] - }, - "input": { - "type": "object" - }, - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "start" - ], - "additionalProperties": false - } - }, - "required": [ - "status", - "input", - "time" - ], - "additionalProperties": false - }, - "SessionV1.ToolStateCompleted": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "completed" - ] - }, - "input": { - "type": "object" - }, - "output": { - "type": "string" - }, - "title": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "compacted": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "start", - "end" - ], - "additionalProperties": false - }, - "attachments": { - "anyOf": [ - { - "type": "array", - "items": { - "$ref": "#/components/schemas/SessionV1.FilePart" - } - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "status", - "input", - "output", - "title", - "metadata", - "time" - ], - "additionalProperties": false - }, - "SessionV1.ToolStateError": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "error" - ] - }, - "input": { - "type": "object" - }, - "error": { - "type": "string" - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "start", - "end" - ], - "additionalProperties": false - } - }, - "required": [ - "status", - "input", - "error", - "time" - ], - "additionalProperties": false - }, - "SessionV1.ToolState": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.ToolStatePending" - }, - { - "$ref": "#/components/schemas/SessionV1.ToolStateRunning" - }, - { - "$ref": "#/components/schemas/SessionV1.ToolStateCompleted" - }, - { - "$ref": "#/components/schemas/SessionV1.ToolStateError" - } - ] - }, - "SessionV1.ToolPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "tool" - ] - }, - "callID": { - "type": "string" - }, - "tool": { - "type": "string" - }, - "state": { - "$ref": "#/components/schemas/SessionV1.ToolState" - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "callID", - "tool", - "state" - ], - "additionalProperties": false - }, - "SessionV1.StepStartPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "step-start" - ] - }, - "snapshot": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type" - ], - "additionalProperties": false - }, - "SessionV1.StepFinishPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "step-finish" - ] - }, - "reason": { - "type": "string" - }, - "snapshot": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "total": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "reason", - "cost", - "tokens" - ], - "additionalProperties": false - }, - "SessionV1.SnapshotPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "snapshot" - ] - }, - "snapshot": { - "type": "string" - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "snapshot" - ], - "additionalProperties": false - }, - "SessionV1.PatchPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "patch" - ] - }, - "hash": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "hash", - "files" - ], - "additionalProperties": false - }, - "SessionV1.AgentPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "agent" - ] - }, - "name": { - "type": "string" - }, - "source": { - "anyOf": [ - { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "value", - "start", - "end" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "name" - ], - "additionalProperties": false - }, - "SessionV1.RetryPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "retry" - ] - }, - "attempt": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "error": { - "$ref": "#/components/schemas/APIError" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "created" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "attempt", - "error", - "time" - ], - "additionalProperties": false - }, - "SessionV1.CompactionPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "compaction" - ] - }, - "auto": { - "type": "boolean" - }, - "overflow": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "tail_start_id": { - "anyOf": [ - { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "auto" - ], - "additionalProperties": false - }, - "SessionV1.Part": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.TextPart" - }, - { - "$ref": "#/components/schemas/SessionV1.SubtaskPart" - }, - { - "$ref": "#/components/schemas/SessionV1.ReasoningPart" - }, - { - "$ref": "#/components/schemas/SessionV1.FilePart" - }, - { - "$ref": "#/components/schemas/SessionV1.ToolPart" - }, - { - "$ref": "#/components/schemas/SessionV1.StepStartPart" - }, - { - "$ref": "#/components/schemas/SessionV1.StepFinishPart" - }, - { - "$ref": "#/components/schemas/SessionV1.SnapshotPart" - }, - { - "$ref": "#/components/schemas/SessionV1.PatchPart" - }, - { - "$ref": "#/components/schemas/SessionV1.AgentPart" - }, - { - "$ref": "#/components/schemas/SessionV1.RetryPart" - }, - { - "$ref": "#/components/schemas/SessionV1.CompactionPart" - } - ] - }, - "message.part.updated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "message.part.updated" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "part": { - "$ref": "#/components/schemas/SessionV1.Part" - }, - "time": { - "type": "number" - } - }, - "required": [ - "sessionID", - "part", - "time" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "message.part.removed": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "message.part.removed" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "partID": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - } - }, - "required": [ - "sessionID", - "messageID", - "partID" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, "session.usage.updated": { "type": "object", "properties": { @@ -25596,7 +22783,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "delta": { @@ -25606,7 +22793,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "delta" ], "additionalProperties": false @@ -25665,7 +22852,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "metadata": { @@ -25675,7 +22862,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "metadata" ], "additionalProperties": false @@ -26757,13 +23944,13 @@ "messageID": { "type": "string" }, - "callID": { + "id": { "type": "string" } }, "required": [ "messageID", - "callID" + "id" ], "additionalProperties": false }, @@ -28442,97 +25629,6 @@ ], "additionalProperties": false }, - "session.error": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.error" - ] - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "anyOf": [ - { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - { - "type": "null" - } - ] - }, - "error": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/components/schemas/ProviderAuthError" - }, - { - "$ref": "#/components/schemas/UnknownError1" - }, - { - "$ref": "#/components/schemas/MessageOutputLengthError" - }, - { - "$ref": "#/components/schemas/MessageAbortedError" - }, - { - "$ref": "#/components/schemas/StructuredOutputError" - }, - { - "$ref": "#/components/schemas/ContextOverflowError" - }, - { - "$ref": "#/components/schemas/ContentFilterError" - }, - { - "$ref": "#/components/schemas/APIError" - } - ] - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "data" - ], - "additionalProperties": false - }, "V2Event.server.connected": { "type": "object", "properties": { @@ -28608,24 +25704,6 @@ { "$ref": "#/components/schemas/session.created" }, - { - "$ref": "#/components/schemas/session.updated" - }, - { - "$ref": "#/components/schemas/session.deleted1" - }, - { - "$ref": "#/components/schemas/message.updated" - }, - { - "$ref": "#/components/schemas/message.removed" - }, - { - "$ref": "#/components/schemas/message.part.updated" - }, - { - "$ref": "#/components/schemas/message.part.removed" - }, { "$ref": "#/components/schemas/session.agent.selected" }, @@ -28860,9 +25938,6 @@ { "$ref": "#/components/schemas/mcp.resources.changed" }, - { - "$ref": "#/components/schemas/session.error" - }, { "$ref": "#/components/schemas/V2Event.server.connected" } @@ -29490,6 +26565,9 @@ { "name": "debug" }, + { + "name": "migration" + }, { "name": "websearch", "description": "Location-scoped web search routes." diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 2249797df4a..6534f132439 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -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 = | HttpApiGroup.AddMiddleware @@ -55,7 +56,7 @@ type LocationGroups = type SessionGroups = | ReturnType> - | HttpApiGroup.AddMiddleware + | typeof MessageGroup type FormGroups< LocationId extends HttpApiMiddleware.AnyId, @@ -85,6 +86,7 @@ type ApiGroups< | typeof HealthGroup | typeof ServerGroup | typeof DebugGroup + | typeof MigrationGroup | LocationGroups | FormGroups | SessionGroups @@ -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({ diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 24e68e7a516..9a809606353 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -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", diff --git a/packages/protocol/src/groups/migration.ts b/packages/protocol/src/groups/migration.ts new file mode 100644 index 00000000000..6ac4eccb3a5 --- /dev/null +++ b/packages/protocol/src/groups/migration.ts @@ -0,0 +1,28 @@ +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const V1MigrationProgress = Schema.Struct({ + label: Schema.String, + numerator: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + denominator: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), +}) + +export const V1MigrationStatus = Schema.Union([ + Schema.Struct({ status: Schema.Literals(["required", "completed"]) }), + Schema.Struct({ status: Schema.Literal("running"), progress: V1MigrationProgress }), + Schema.Struct({ status: Schema.Literal("error"), error: Schema.String }), +]) + +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.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "migration" })) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 2abe6c47c73..3a4b4e99d8f 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -181,7 +181,6 @@ export const makeSessionGroup = (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 = (sessionLo success: HttpApiSchema.NoContent, error: [SessionNotFoundError, InvalidRequestError], }) - .middleware(sessionLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.move", @@ -470,7 +468,6 @@ export const makeSessionGroup = (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 = (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 = (sessionLo }), error: SessionNotFoundError, }) - .middleware(sessionLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.log", @@ -620,7 +615,6 @@ export const makeSessionGroup = (sessionLo success: Schema.Struct({ data: SessionMessage.Info }), error: [SessionNotFoundError, MessageNotFoundError], }) - .middleware(sessionLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.message", diff --git a/packages/schema/src/durable-event-manifest.ts b/packages/schema/src/durable-event-manifest.ts index 73c85050622..a077b1fa32f 100644 --- a/packages/schema/src/durable-event-manifest.ts +++ b/packages/schema/src/durable-event-manifest.ts @@ -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) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 084c41dd7ae..1898f50cb78 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -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, diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index a194604e0d9..a47be319415 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -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, @@ -552,6 +570,7 @@ export namespace RevertEvent { } export const Definitions = Event.inventory( + Created, AgentSelected, ModelSelected, Moved, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 40955425c79..5f2872f381e 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -19,7 +19,6 @@ import { Plugin } from "../src/plugin.js" import { SessionEvent } from "../src/session-event.js" import { SessionID } from "../src/session-id.js" import { SessionMessage } from "../src/session-message.js" -import { SessionV1 } from "../src/session-v1.js" import { WorkspaceEvent } from "../src/workspace-event.js" describe("public event manifest", () => { @@ -32,18 +31,6 @@ describe("public event manifest", () => { expect(EventManifest.Definitions.filter((definition) => definition.type === "agent.updated")).toEqual([ Agent.Event.Updated, ]) - expect(SessionV1.Event.Definitions).toEqual([ - SessionV1.Event.Created, - SessionV1.Event.Updated, - SessionV1.Event.Deleted, - SessionV1.Event.MessageUpdated, - SessionV1.Event.MessageRemoved, - SessionV1.Event.PartUpdated, - SessionV1.Event.PartRemoved, - SessionV1.Event.PartDelta, - SessionV1.Event.Diff, - SessionV1.Event.Error, - ]) expect(Array.from(EventManifest.Latest.keys())).toEqual( Array.from(new Set(EventManifest.Definitions.map((definition) => definition.type))), ) @@ -51,6 +38,7 @@ describe("public event manifest", () => { expect(EventManifest.Latest.get("plugin.updated")).toBe(Plugin.Event.Updated) expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged) expect(EventManifest.Server.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged) + expect(EventManifest.Server.get("session.created")).toBe(SessionEvent.Created) expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted) expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false) expect(Agent.Event.Updated.durable).toBeUndefined() @@ -79,12 +67,6 @@ describe("public event manifest", () => { expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) - const sessionV1TailStart = EventManifest.Definitions.indexOf(SessionV1.Event.PartDelta) - expect(EventManifest.Definitions.slice(sessionV1TailStart, sessionV1TailStart + 3)).toEqual([ - SessionV1.Event.PartDelta, - SessionV1.Event.Diff, - SessionV1.Event.Error, - ]) expect(EventManifest.Durable.get("session.step.ended.1")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Durable.has("session.step.ended.2")).toBe(false) }) @@ -93,13 +75,7 @@ describe("public event manifest", () => { expect(Array.from(EventManifest.Durable.keys()).toSorted()).toEqual( [ "session.created.1", - "session.updated.1", - "session.deleted.1", "session.deleted.2", - "message.updated.1", - "message.removed.1", - "message.part.updated.1", - "message.part.removed.1", "session.agent.selected.1", "session.model.selected.1", "session.moved.1", diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index a0126ea89e7..7f96b05fce9 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -28,11 +28,13 @@ import { ProjectHandler } from "./handlers/project" import { ProjectCopyHandler } from "./handlers/project-copy" import { VcsHandler } from "./handlers/vcs" import { EventFeed } from "./event-feed" +import { MigrationHandler } from "./handlers/migration" export const handlers = Layer.mergeAll( HealthHandler, ServerHandler, DebugHandler, + MigrationHandler, LocationHandler, AgentHandler, PluginHandler, diff --git a/packages/server/src/handlers/migration.ts b/packages/server/src/handlers/migration.ts new file mode 100644 index 00000000000..230f69d73e4 --- /dev/null +++ b/packages/server/src/handlers/migration.ts @@ -0,0 +1,13 @@ +import { V1Migration } from "@opencode-ai/core/database/v1-migration" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Effect } from "effect" +import { Api } from "../api" + +export const MigrationHandler = HttpApiBuilder.group(Api, "server.migration", (handlers) => + handlers.handle( + "migration.v1.status", + Effect.fn(function* () { + return yield* V1Migration.status() + }), + ), +) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index a62ccfcd6ca..c3c1559a704 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -650,13 +650,21 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl ) .handle( "session.log", - Effect.fn((ctx) => - Effect.succeed( - session - .log({ sessionID: ctx.params.sessionID, after: ctx.query.after, follow: ctx.query.follow }) - .pipe(Stream.orDie), - ), - ), + Effect.fn(function* (ctx) { + yield* session.get(ctx.params.sessionID).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ) + return session + .log({ sessionID: ctx.params.sessionID, after: ctx.query.after, follow: ctx.query.follow }) + .pipe(Stream.orDie) + }), ) .handle( "session.interrupt", @@ -684,6 +692,16 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.message", Effect.fn(function* (ctx) { + yield* session.get(ctx.params.sessionID).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ) const message = yield* session.message(ctx.params) if (message) return { data: message } return yield* new MessageNotFoundError({ diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 483745b404f..128685ed153 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -31,6 +31,14 @@ type App = Effect.Effect< HttpServerRequest.HttpServerRequest | Scope.Scope > +const errorResponseLogger = HttpMiddleware.make((app) => + HttpMiddleware.logger( + Effect.tap(app, (response) => + response.status < 400 ? HttpMiddleware.withLoggerDisabled(Effect.void) : Effect.void, + ), + ), +) + export const start = Effect.fn("ServerProcess.start")(function* ( options: ServerOptions, lifecycle?: Lifecycle, @@ -52,7 +60,7 @@ export const start = Effect.fn("ServerProcess.start")(function* ( dispatch(password, status, application, shutdown, options.app?.version ?? "unknown").pipe( HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }), ), - HttpMiddleware.logger, + errorResponseLogger, ) .pipe(withoutParentSpan) if (lifecycle) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index f75c01cf092..6f1efa1cb96 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -1,4 +1,5 @@ import { Database } from "@opencode-ai/core/database/database" +import { V1Migration } from "@opencode-ai/core/database/v1-migration" import { App } from "@opencode-ai/core/app" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { httpClient } from "@opencode-ai/util/effect/app-node-platform" @@ -133,10 +134,12 @@ function makeRoutes( Layer.flatMap((context) => { const services = Layer.succeedContext(context) const requestServices = Layer.merge( - Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)), + Layer.succeedContext( + Context.pick(Database.Service, PermissionSaved.Service, Project.Service, WellKnown.Service)(context), + ), ServerInfo.layer(serviceURLs, options.app), ) - return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( + const api = HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers.pipe(Layer.provide(services))), Layer.provide(formLocationLayer), Layer.provide(sessionLocationLayer), @@ -148,6 +151,7 @@ function makeRoutes( Layer.provideMerge(services), Layer.provideMerge(HttpRouter.layer), ) + return Layer.merge(api, V1Migration.layer.pipe(Layer.provide(services))) }), Layer.provide(observability), ) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index d692b4e50e8..376963cddbe 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -30,6 +30,7 @@ import { batch, Show, } from "solid-js" +import { createStore } from "solid-js/store" import { TuiLifecycleProvider, TuiAppProvider, @@ -50,6 +51,7 @@ import { ClientProvider, useClient } from "./context/client" import { StartupLoading } from "./component/startup-loading" import { DevToolsBar } from "./component/devtools-bar" import { Reconnecting } from "./component/reconnecting" +import { MigrationOverlay } from "./component/migration-overlay" import { DataProvider, useData } from "./context/data" import { SessionTabsProvider, useSessionTabs } from "./context/session-tabs" import { LocationProvider, useLocation } from "./context/location" @@ -187,21 +189,6 @@ export type TuiInput = { log?: LogSink } -function errorMessage(error: unknown) { - if ( - typeof error === "object" && - error !== null && - "data" in error && - typeof error.data === "object" && - error.data !== null && - "message" in error.data && - typeof error.data.message === "string" - ) { - return error.data.message - } - return error instanceof Error ? error.message : String(error) -} - export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const log = input.log ?? (() => {}) const global = yield* Global.Service @@ -215,9 +202,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { Effect.catch(() => Effect.tryPromise(() => api.location.get())), ) const directory = location.directory - const pluginDirectories = yield* Effect.promise(() => - tuiPluginDirectories(process.cwd(), global.config), - ) + const pluginDirectories = yield* Effect.promise(() => tuiPluginDirectories(process.cwd(), global.config)) const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined const managed = input.server.service const service = managed @@ -472,7 +457,6 @@ function App(props: { pair?: DialogPairCredentials }) { const promptRef = usePromptRef() const plugins = usePlugin() const clipboard = useClipboard() - // Toast once when an MCP server enters a failed or needs-auth state so the user knows to act, // without having to open the status panel. Tracking the last alerted status avoids re-toasting // the same problem on every refresh while still re-alerting if the state changes. @@ -1170,15 +1154,11 @@ function App(props: { pair?: DialogPairCredentials }) { } }) - event.on("session.error", (evt, { workspace }) => { + event.on("session.execution.failed", (evt, { workspace }) => { if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return - const error = evt.data.error - if (error && typeof error === "object" && error.name === "MessageAbortedError") return - const message = errorMessage(error) - toast.show({ variant: "error", - message, + message: evt.data.error.message, duration: 5000, }) }) @@ -1266,6 +1246,7 @@ function App(props: { pair?: DialogPairCredentials }) { + ) diff --git a/packages/tui/src/component/migration-overlay.tsx b/packages/tui/src/component/migration-overlay.tsx new file mode 100644 index 00000000000..21943529af7 --- /dev/null +++ b/packages/tui/src/component/migration-overlay.tsx @@ -0,0 +1,72 @@ +import { createSignal, onCleanup, onMount, Show } from "solid-js" +import { useClient } from "../context/client" +import { useTheme } from "../context/theme" +import { SplitBorder } from "../ui/border" +import { useToast } from "../ui/toast" +import { Spinner } from "./spinner" + +type Progress = { label: string; numerator?: number; denominator?: number } + +export function MigrationOverlay() { + const client = useClient() + const toast = useToast() + const theme = useTheme("overlay") + const [progress, setProgress] = createSignal() + const abort = new AbortController() + + onMount(async () => { + await Bun.sleep(1_000) + void (async () => { + while (true) { + const status = await client.api.migration.v1.status({ signal: abort.signal }) + setProgress(status.status === "running" ? status.progress : undefined) + if (status.status === "completed") return + if (status.status === "error") throw new Error(status.error) + await Bun.sleep(1_000) + } + })().catch((error) => { + if (abort.signal.aborted) return + setProgress(undefined) + toast.show({ + variant: "error", + title: "Data migration failed", + message: error instanceof Error ? error.message : String(error), + duration: 10_000, + }) + }) + }) + onCleanup(() => abort.abort()) + + const count = (value: Progress) => { + if (value.numerator === undefined) return "" + if (value.denominator === undefined) return ` ${value.numerator}` + return ` ${value.numerator}/${value.denominator}` + } + + return ( + + {(value) => ( + + + {value().label} + {count(value())} + + + )} + + ) +} diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index f99e729e82f..528e5b9bcb6 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -204,11 +204,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 })) }), ) - onCleanup( - event.on("session.error", (evt) => { - if (evt.data.sessionID) markUnread(evt.data.sessionID, "error") - }), - ) onCleanup( event.on("session.deleted", (evt) => { const target = root(evt.data.sessionID) @@ -220,8 +215,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp function remove(sessionID: string, navigate: boolean) { const target = root(sessionID) const closed = closeSessionTab(state().tabs, target) - if (closed.tabs === state().tabs) return const selected = navigate && current() === target + if (closed.tabs === state().tabs && !selected) return const previous = selected ? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1) : { history, sessionID: undefined } diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index fd5d35b9b19..180b345ca0f 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -1,8 +1,5 @@ import { Plugin } from "@opencode-ai/plugin/tui" import type { AttentionSoundName } from "@opencode-ai/plugin/tui/context" -import type { OpenCodeEvent } from "@opencode-ai/client" - -type SessionError = Extract["data"]["error"] function notify( context: Plugin.Context, @@ -21,15 +18,6 @@ function notify( }) } -function sessionErrorMessage(error: SessionError) { - if (error?.name === "MessageAbortedError") return "Session aborted" - const data = error?.data - if (data && typeof data === "object" && "message" in data && data.message === "SSE read timed out") { - return "Model stopped responding" - } - return "Session error" -} - export default Plugin.define({ id: "opencode.notifications", setup(context) { @@ -88,14 +76,6 @@ export default Plugin.define({ notify(context, sessionID, event.data.error.message, "error") ended(sessionID) }), - context.data.on("session.error", (event) => { - const sessionID = event.data.sessionID - if (!sessionID) return - if (context.data.session.status(sessionID) !== "running") return - if (errored.has(sessionID)) return - errored.add(sessionID) - notify(context, sessionID, sessionErrorMessage(event.data.error), "error") - }), ] return () => dispose.reverse().forEach((cleanup) => cleanup()) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index b69dc8a1d25..dab3cf68200 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -96,6 +96,7 @@ import { findMessageBoundary, messageNavigationSlack } from "./message-navigatio import { stringWidth } from "../../util/string-width" import { useArgs } from "../../context/args" import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback" +import { useSessionTabs } from "../../context/session-tabs" addDefaultParsers(parsers.parsers) @@ -244,6 +245,7 @@ export function Session() { const [navigationMessage, setNavigationMessage] = createSignal() const [navigationSlack, setNavigationSlack] = createSignal(0) const [synced, setSynced] = createSignal(false) + const sessionTabs = useSessionTabs() const clearMessageNavigation = () => { setNavigationSlack(0) @@ -262,7 +264,7 @@ export function Session() { createEffect( on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => { if (status !== "connected") return - void Promise.all( + void Promise.allSettled( sessionIDs.flatMap((sessionID) => [data.session.permission.sync(sessionID), data.session.form.sync(sessionID)]), ) }), @@ -275,8 +277,8 @@ export function Session() { void (async () => { await Promise.all([ data.session.sync(sessionID, { children: true }), - data.session.permission.sync(sessionID), - data.session.form.sync(sessionID), + data.session.permission.sync(sessionID).catch(() => undefined), + data.session.form.sync(sessionID).catch(() => undefined), ]) const info = data.session.get(sessionID) if (!info) { @@ -285,7 +287,7 @@ export function Session() { variant: "error", duration: 5000, }) - navigate({ type: "home" }) + sessionTabs.enabled() ? sessionTabs.close(sessionID) : navigate({ type: "home" }) return } editor.reconnect(info.location.directory) @@ -298,7 +300,7 @@ export function Session() { variant: "error", duration: 5000, }) - navigate({ type: "home" }) + sessionTabs.enabled() ? sessionTabs.close(sessionID) : navigate({ type: "home" }) }) }) diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 6574874c8ab..1b98d9e7881 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -293,39 +293,4 @@ describe("internal notifications TUI plugin", () => { }, ]) }) - - test("special-cases aborts and model response timeouts", async () => { - const harness = await setup() - - harness.emit(executionStarted("event-1", "abort")) - harness.emit({ - id: "event-2", - created: 0, - type: "session.error", - data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } }, - }) - harness.emit(executionStarted("event-3", "timeout")) - harness.emit({ - id: "event-4", - created: 0, - type: "session.error", - data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } }, - }) - harness.emit(executionFailed("event-5", "timeout")) - - expect(harness.notifications).toEqual([ - { - title: "Abort session", - message: "Session aborted", - notification: { when: "blurred" }, - sound: { name: "error", when: "always" }, - }, - { - title: "Timeout session", - message: "Model stopped responding", - notification: { when: "blurred" }, - sound: { name: "error", when: "always" }, - }, - ]) - }) }) diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 66907577a98..b33dfeb13be 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -11757,6 +11757,136 @@ "summary": "Evict a loaded location" } }, + "/api/experimental/migration/v1": { + "get": { + "tags": [ + "migration" + ], + "operationId": "v2.experimental.migration.v1.status", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "required", + "running", + "completed" + ] + }, + "completed": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "total": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "status", + "completed", + "total" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Return the progress of the V1 to V2 session history migration.", + "summary": "Get V1 migration status" + }, + "post": { + "tags": [ + "migration" + ], + "operationId": "v2.experimental.migration.v1.run", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "completed" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Run or resume the V1 to V2 session history migration and wait for completion.", + "summary": "Run V1 migration" + } + }, "/api/websearch/provider": { "get": { "tags": [ @@ -14518,6 +14648,123 @@ ], "additionalProperties": false }, + "session.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.created" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "projectID": { + "type": "string" + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "subpath": { + "type": "string" + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "version": { + "type": "string" + } + }, + "required": [ + "sessionID", + "projectID", + "location", + "slug", + "version" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, "session.agent.selected": { "type": "object", "properties": { @@ -17072,7 +17319,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "name": { @@ -17082,7 +17329,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "name" ], "additionalProperties": false @@ -17170,7 +17417,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "text": { @@ -17180,7 +17427,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "text" ], "additionalProperties": false @@ -17271,7 +17518,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "input": { @@ -17287,7 +17534,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "input", "executed" ], @@ -17422,7 +17669,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "content": { @@ -17450,7 +17697,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "content", "executed" ], @@ -17542,7 +17789,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "error": { @@ -17573,7 +17820,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "error", "executed" ], @@ -18445,6 +18692,9 @@ }, "Session.Event.Durable": { "oneOf": [ + { + "$ref": "#/components/schemas/session.created" + }, { "$ref": "#/components/schemas/session.agent.selected" }, @@ -21855,14 +22105,14 @@ "messageID": { "type": "string" }, - "callID": { + "id": { "type": "string" } }, "required": [ "type", "messageID", - "callID" + "id" ], "additionalProperties": false } @@ -22280,3069 +22530,6 @@ ], "additionalProperties": false }, - "FileDiff.LegacyInfo": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "patch": { - "type": "string" - }, - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "status": { - "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] - } - }, - "required": [ - "additions", - "deletions" - ], - "additionalProperties": false - }, - "PermissionV1.Action": { - "type": "string", - "enum": [ - "allow", - "deny", - "ask" - ] - }, - "PermissionV1.Rule": { - "type": "object", - "properties": { - "permission": { - "type": "string" - }, - "pattern": { - "type": "string" - }, - "action": { - "$ref": "#/components/schemas/PermissionV1.Action" - } - }, - "required": [ - "permission", - "pattern", - "action" - ], - "additionalProperties": false - }, - "PermissionV1.Ruleset": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PermissionV1.Rule" - } - }, - "SessionV1.Info": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" - }, - "workspaceID": { - "type": "string", - "allOf": [ - { - "pattern": "^wrk" - } - ] - }, - "directory": { - "type": "string" - }, - "path": { - "type": "string" - }, - "parentID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "summary": { - "type": "object", - "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff.LegacyInfo" - } - } - }, - "required": [ - "additions", - "deletions", - "files" - ], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false - }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": [ - "url" - ], - "additionalProperties": false - }, - "title": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": [ - "id", - "providerID" - ], - "additionalProperties": false - }, - "version": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "updated": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "compacting": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "archived": { - "type": "number" - } - }, - "required": [ - "created", - "updated" - ], - "additionalProperties": false - }, - "permission": { - "$ref": "#/components/schemas/PermissionV1.Ruleset" - }, - "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "partID": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": [ - "messageID" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "slug", - "projectID", - "directory", - "version", - "time" - ], - "additionalProperties": false - }, - "session.created": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.created" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "info": { - "$ref": "#/components/schemas/SessionV1.Info" - } - }, - "required": [ - "sessionID", - "info" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "session.updated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.updated" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "info": { - "$ref": "#/components/schemas/SessionV1.Info" - } - }, - "required": [ - "sessionID", - "info" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "session.deleted1": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.deleted" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "info": { - "$ref": "#/components/schemas/SessionV1.Info" - } - }, - "required": [ - "sessionID", - "info" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "SessionV1.JSONSchema": { - "type": "object" - }, - "SessionV1.OutputFormat": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ] - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "json_schema" - ] - }, - "schema": { - "$ref": "#/components/schemas/SessionV1.JSONSchema" - }, - "retryCount": { - "anyOf": [ - { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "type", - "schema" - ], - "additionalProperties": false - } - ] - }, - "SessionV1.UserMessage": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "role": { - "type": "string", - "enum": [ - "user" - ] - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "number", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "created" - ], - "additionalProperties": false - }, - "format": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.OutputFormat" - }, - { - "type": "null" - } - ] - }, - "summary": { - "anyOf": [ - { - "type": "object", - "properties": { - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "body": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff.LegacyInfo" - } - } - }, - "required": [ - "diffs" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - }, - "variant": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "providerID", - "modelID" - ], - "additionalProperties": false - }, - "system": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "type": "boolean" - } - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "role", - "time", - "agent", - "model" - ], - "additionalProperties": false - }, - "ProviderAuthError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "ProviderAuthError" - ] - }, - "data": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": [ - "providerID", - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "UnknownError1": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "UnknownError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "ref": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "MessageOutputLengthError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "MessageOutputLengthError" - ] - }, - "data": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "array" - } - ] - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "MessageAbortedError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "MessageAbortedError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "StructuredOutputError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "StructuredOutputError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "retries": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "message", - "retries" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "ContextOverflowError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "ContextOverflowError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "responseBody": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "ContentFilterError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "ContentFilterError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "APIError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "APIError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "statusCode": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - }, - "isRetryable": { - "type": "boolean" - }, - "responseHeaders": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ] - }, - "responseBody": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "metadata": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "message", - "isRetryable" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "SessionV1.AssistantMessage": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "role": { - "type": "string", - "enum": [ - "assistant" - ] - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "completed": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "created" - ], - "additionalProperties": false - }, - "error": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/components/schemas/ProviderAuthError" - }, - { - "$ref": "#/components/schemas/UnknownError1" - }, - { - "$ref": "#/components/schemas/MessageOutputLengthError" - }, - { - "$ref": "#/components/schemas/MessageAbortedError" - }, - { - "$ref": "#/components/schemas/StructuredOutputError" - }, - { - "$ref": "#/components/schemas/ContextOverflowError" - }, - { - "$ref": "#/components/schemas/ContentFilterError" - }, - { - "$ref": "#/components/schemas/APIError" - } - ] - }, - { - "type": "null" - } - ] - }, - "parentID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "modelID": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "mode": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "path": { - "type": "object", - "properties": { - "cwd": { - "type": "string" - }, - "root": { - "type": "string" - } - }, - "required": [ - "cwd", - "root" - ], - "additionalProperties": false - }, - "summary": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "total": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false - }, - "structured": { - "anyOf": [ - {}, - { - "type": "null" - } - ] - }, - "variant": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "finish": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "role", - "time", - "parentID", - "modelID", - "providerID", - "mode", - "agent", - "path", - "cost", - "tokens" - ], - "additionalProperties": false - }, - "SessionV1.Message": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.UserMessage" - }, - { - "$ref": "#/components/schemas/SessionV1.AssistantMessage" - } - ] - }, - "message.updated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "message.updated" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "info": { - "$ref": "#/components/schemas/SessionV1.Message" - } - }, - "required": [ - "sessionID", - "info" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "message.removed": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "message.removed" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - } - }, - "required": [ - "sessionID", - "messageID" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "SessionV1.TextPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "text" - ] - }, - "text": { - "type": "string" - }, - "synthetic": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "ignored": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "time": { - "anyOf": [ - { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "start" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text" - ], - "additionalProperties": false - }, - "SessionV1.SubtaskPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "subtask" - ] - }, - "prompt": { - "type": "string" - }, - "description": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "anyOf": [ - { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": [ - "providerID", - "modelID" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "command": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "prompt", - "description", - "agent" - ], - "additionalProperties": false - }, - "SessionV1.ReasoningPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "reasoning" - ] - }, - "text": { - "type": "string" - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "start" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text", - "time" - ], - "additionalProperties": false - }, - "SessionV1.FilePartSourceText": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "start": { - "type": "number" - }, - "end": { - "type": "number" - } - }, - "required": [ - "value", - "start", - "end" - ], - "additionalProperties": false - }, - "SessionV1.FileSource": { - "type": "object", - "properties": { - "text": { - "$ref": "#/components/schemas/SessionV1.FilePartSourceText" - }, - "type": { - "type": "string", - "enum": [ - "file" - ] - }, - "path": { - "type": "string" - } - }, - "required": [ - "text", - "type", - "path" - ], - "additionalProperties": false - }, - "SessionV1.Range": { - "type": "object", - "properties": { - "start": { - "type": "object", - "properties": { - "line": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "character": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "line", - "character" - ], - "additionalProperties": false - }, - "end": { - "type": "object", - "properties": { - "line": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "character": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "line", - "character" - ], - "additionalProperties": false - } - }, - "required": [ - "start", - "end" - ], - "additionalProperties": false - }, - "SessionV1.SymbolSource": { - "type": "object", - "properties": { - "text": { - "$ref": "#/components/schemas/SessionV1.FilePartSourceText" - }, - "type": { - "type": "string", - "enum": [ - "symbol" - ] - }, - "path": { - "type": "string" - }, - "range": { - "$ref": "#/components/schemas/SessionV1.Range" - }, - "name": { - "type": "string" - }, - "kind": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "text", - "type", - "path", - "range", - "name", - "kind" - ], - "additionalProperties": false - }, - "SessionV1.ResourceSource": { - "type": "object", - "properties": { - "text": { - "$ref": "#/components/schemas/SessionV1.FilePartSourceText" - }, - "type": { - "type": "string", - "enum": [ - "resource" - ] - }, - "clientName": { - "type": "string" - }, - "uri": { - "type": "string" - } - }, - "required": [ - "text", - "type", - "clientName", - "uri" - ], - "additionalProperties": false - }, - "SessionV1.FilePartSource": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.FileSource" - }, - { - "$ref": "#/components/schemas/SessionV1.SymbolSource" - }, - { - "$ref": "#/components/schemas/SessionV1.ResourceSource" - } - ] - }, - "SessionV1.FilePart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "file" - ] - }, - "mime": { - "type": "string" - }, - "filename": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "url": { - "type": "string" - }, - "source": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.FilePartSource" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "mime", - "url" - ], - "additionalProperties": false - }, - "SessionV1.ToolStatePending": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "pending" - ] - }, - "input": { - "type": "object" - }, - "raw": { - "type": "string" - } - }, - "required": [ - "status", - "input", - "raw" - ], - "additionalProperties": false - }, - "SessionV1.ToolStateRunning": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "running" - ] - }, - "input": { - "type": "object" - }, - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "start" - ], - "additionalProperties": false - } - }, - "required": [ - "status", - "input", - "time" - ], - "additionalProperties": false - }, - "SessionV1.ToolStateCompleted": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "completed" - ] - }, - "input": { - "type": "object" - }, - "output": { - "type": "string" - }, - "title": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "compacted": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "start", - "end" - ], - "additionalProperties": false - }, - "attachments": { - "anyOf": [ - { - "type": "array", - "items": { - "$ref": "#/components/schemas/SessionV1.FilePart" - } - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "status", - "input", - "output", - "title", - "metadata", - "time" - ], - "additionalProperties": false - }, - "SessionV1.ToolStateError": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "error" - ] - }, - "input": { - "type": "object" - }, - "error": { - "type": "string" - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "start", - "end" - ], - "additionalProperties": false - } - }, - "required": [ - "status", - "input", - "error", - "time" - ], - "additionalProperties": false - }, - "SessionV1.ToolState": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.ToolStatePending" - }, - { - "$ref": "#/components/schemas/SessionV1.ToolStateRunning" - }, - { - "$ref": "#/components/schemas/SessionV1.ToolStateCompleted" - }, - { - "$ref": "#/components/schemas/SessionV1.ToolStateError" - } - ] - }, - "SessionV1.ToolPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "tool" - ] - }, - "callID": { - "type": "string" - }, - "tool": { - "type": "string" - }, - "state": { - "$ref": "#/components/schemas/SessionV1.ToolState" - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "callID", - "tool", - "state" - ], - "additionalProperties": false - }, - "SessionV1.StepStartPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "step-start" - ] - }, - "snapshot": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type" - ], - "additionalProperties": false - }, - "SessionV1.StepFinishPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "step-finish" - ] - }, - "reason": { - "type": "string" - }, - "snapshot": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "total": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "reason", - "cost", - "tokens" - ], - "additionalProperties": false - }, - "SessionV1.SnapshotPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "snapshot" - ] - }, - "snapshot": { - "type": "string" - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "snapshot" - ], - "additionalProperties": false - }, - "SessionV1.PatchPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "patch" - ] - }, - "hash": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "hash", - "files" - ], - "additionalProperties": false - }, - "SessionV1.AgentPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "agent" - ] - }, - "name": { - "type": "string" - }, - "source": { - "anyOf": [ - { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "value", - "start", - "end" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "name" - ], - "additionalProperties": false - }, - "SessionV1.RetryPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "retry" - ] - }, - "attempt": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "error": { - "$ref": "#/components/schemas/APIError" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "created" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "attempt", - "error", - "time" - ], - "additionalProperties": false - }, - "SessionV1.CompactionPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "compaction" - ] - }, - "auto": { - "type": "boolean" - }, - "overflow": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "tail_start_id": { - "anyOf": [ - { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "auto" - ], - "additionalProperties": false - }, - "SessionV1.Part": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.TextPart" - }, - { - "$ref": "#/components/schemas/SessionV1.SubtaskPart" - }, - { - "$ref": "#/components/schemas/SessionV1.ReasoningPart" - }, - { - "$ref": "#/components/schemas/SessionV1.FilePart" - }, - { - "$ref": "#/components/schemas/SessionV1.ToolPart" - }, - { - "$ref": "#/components/schemas/SessionV1.StepStartPart" - }, - { - "$ref": "#/components/schemas/SessionV1.StepFinishPart" - }, - { - "$ref": "#/components/schemas/SessionV1.SnapshotPart" - }, - { - "$ref": "#/components/schemas/SessionV1.PatchPart" - }, - { - "$ref": "#/components/schemas/SessionV1.AgentPart" - }, - { - "$ref": "#/components/schemas/SessionV1.RetryPart" - }, - { - "$ref": "#/components/schemas/SessionV1.CompactionPart" - } - ] - }, - "message.part.updated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "message.part.updated" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "part": { - "$ref": "#/components/schemas/SessionV1.Part" - }, - "time": { - "type": "number" - } - }, - "required": [ - "sessionID", - "part", - "time" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "message.part.removed": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "message.part.removed" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "partID": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - } - }, - "required": [ - "sessionID", - "messageID", - "partID" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, "session.usage.updated": { "type": "object", "properties": { @@ -25596,7 +22783,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "delta": { @@ -25606,7 +22793,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "delta" ], "additionalProperties": false @@ -25665,7 +22852,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "metadata": { @@ -25675,7 +22862,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "metadata" ], "additionalProperties": false @@ -26757,13 +23944,13 @@ "messageID": { "type": "string" }, - "callID": { + "id": { "type": "string" } }, "required": [ "messageID", - "callID" + "id" ], "additionalProperties": false }, @@ -28442,97 +25629,6 @@ ], "additionalProperties": false }, - "session.error": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.error" - ] - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "anyOf": [ - { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - { - "type": "null" - } - ] - }, - "error": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/components/schemas/ProviderAuthError" - }, - { - "$ref": "#/components/schemas/UnknownError1" - }, - { - "$ref": "#/components/schemas/MessageOutputLengthError" - }, - { - "$ref": "#/components/schemas/MessageAbortedError" - }, - { - "$ref": "#/components/schemas/StructuredOutputError" - }, - { - "$ref": "#/components/schemas/ContextOverflowError" - }, - { - "$ref": "#/components/schemas/ContentFilterError" - }, - { - "$ref": "#/components/schemas/APIError" - } - ] - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "data" - ], - "additionalProperties": false - }, "V2Event.server.connected": { "type": "object", "properties": { @@ -28608,24 +25704,6 @@ { "$ref": "#/components/schemas/session.created" }, - { - "$ref": "#/components/schemas/session.updated" - }, - { - "$ref": "#/components/schemas/session.deleted1" - }, - { - "$ref": "#/components/schemas/message.updated" - }, - { - "$ref": "#/components/schemas/message.removed" - }, - { - "$ref": "#/components/schemas/message.part.updated" - }, - { - "$ref": "#/components/schemas/message.part.removed" - }, { "$ref": "#/components/schemas/session.agent.selected" }, @@ -28860,9 +25938,6 @@ { "$ref": "#/components/schemas/mcp.resources.changed" }, - { - "$ref": "#/components/schemas/session.error" - }, { "$ref": "#/components/schemas/V2Event.server.connected" } @@ -29490,6 +26565,9 @@ { "name": "debug" }, + { + "name": "migration" + }, { "name": "websearch", "description": "Location-scoped web search routes." diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 66907577a98..b33dfeb13be 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -11757,6 +11757,136 @@ "summary": "Evict a loaded location" } }, + "/api/experimental/migration/v1": { + "get": { + "tags": [ + "migration" + ], + "operationId": "v2.experimental.migration.v1.status", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "required", + "running", + "completed" + ] + }, + "completed": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "total": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "status", + "completed", + "total" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Return the progress of the V1 to V2 session history migration.", + "summary": "Get V1 migration status" + }, + "post": { + "tags": [ + "migration" + ], + "operationId": "v2.experimental.migration.v1.run", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "completed" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Run or resume the V1 to V2 session history migration and wait for completion.", + "summary": "Run V1 migration" + } + }, "/api/websearch/provider": { "get": { "tags": [ @@ -14518,6 +14648,123 @@ ], "additionalProperties": false }, + "session.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.created" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "projectID": { + "type": "string" + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "subpath": { + "type": "string" + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "version": { + "type": "string" + } + }, + "required": [ + "sessionID", + "projectID", + "location", + "slug", + "version" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, "session.agent.selected": { "type": "object", "properties": { @@ -17072,7 +17319,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "name": { @@ -17082,7 +17329,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "name" ], "additionalProperties": false @@ -17170,7 +17417,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "text": { @@ -17180,7 +17427,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "text" ], "additionalProperties": false @@ -17271,7 +17518,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "input": { @@ -17287,7 +17534,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "input", "executed" ], @@ -17422,7 +17669,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "content": { @@ -17450,7 +17697,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "content", "executed" ], @@ -17542,7 +17789,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "error": { @@ -17573,7 +17820,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "error", "executed" ], @@ -18445,6 +18692,9 @@ }, "Session.Event.Durable": { "oneOf": [ + { + "$ref": "#/components/schemas/session.created" + }, { "$ref": "#/components/schemas/session.agent.selected" }, @@ -21855,14 +22105,14 @@ "messageID": { "type": "string" }, - "callID": { + "id": { "type": "string" } }, "required": [ "type", "messageID", - "callID" + "id" ], "additionalProperties": false } @@ -22280,3069 +22530,6 @@ ], "additionalProperties": false }, - "FileDiff.LegacyInfo": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "patch": { - "type": "string" - }, - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "status": { - "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] - } - }, - "required": [ - "additions", - "deletions" - ], - "additionalProperties": false - }, - "PermissionV1.Action": { - "type": "string", - "enum": [ - "allow", - "deny", - "ask" - ] - }, - "PermissionV1.Rule": { - "type": "object", - "properties": { - "permission": { - "type": "string" - }, - "pattern": { - "type": "string" - }, - "action": { - "$ref": "#/components/schemas/PermissionV1.Action" - } - }, - "required": [ - "permission", - "pattern", - "action" - ], - "additionalProperties": false - }, - "PermissionV1.Ruleset": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PermissionV1.Rule" - } - }, - "SessionV1.Info": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" - }, - "workspaceID": { - "type": "string", - "allOf": [ - { - "pattern": "^wrk" - } - ] - }, - "directory": { - "type": "string" - }, - "path": { - "type": "string" - }, - "parentID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "summary": { - "type": "object", - "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff.LegacyInfo" - } - } - }, - "required": [ - "additions", - "deletions", - "files" - ], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false - }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": [ - "url" - ], - "additionalProperties": false - }, - "title": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": [ - "id", - "providerID" - ], - "additionalProperties": false - }, - "version": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "updated": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "compacting": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "archived": { - "type": "number" - } - }, - "required": [ - "created", - "updated" - ], - "additionalProperties": false - }, - "permission": { - "$ref": "#/components/schemas/PermissionV1.Ruleset" - }, - "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "partID": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": [ - "messageID" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "slug", - "projectID", - "directory", - "version", - "time" - ], - "additionalProperties": false - }, - "session.created": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.created" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "info": { - "$ref": "#/components/schemas/SessionV1.Info" - } - }, - "required": [ - "sessionID", - "info" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "session.updated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.updated" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "info": { - "$ref": "#/components/schemas/SessionV1.Info" - } - }, - "required": [ - "sessionID", - "info" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "session.deleted1": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.deleted" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "info": { - "$ref": "#/components/schemas/SessionV1.Info" - } - }, - "required": [ - "sessionID", - "info" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "SessionV1.JSONSchema": { - "type": "object" - }, - "SessionV1.OutputFormat": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ] - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "json_schema" - ] - }, - "schema": { - "$ref": "#/components/schemas/SessionV1.JSONSchema" - }, - "retryCount": { - "anyOf": [ - { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "type", - "schema" - ], - "additionalProperties": false - } - ] - }, - "SessionV1.UserMessage": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "role": { - "type": "string", - "enum": [ - "user" - ] - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "number", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "created" - ], - "additionalProperties": false - }, - "format": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.OutputFormat" - }, - { - "type": "null" - } - ] - }, - "summary": { - "anyOf": [ - { - "type": "object", - "properties": { - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "body": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff.LegacyInfo" - } - } - }, - "required": [ - "diffs" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - }, - "variant": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "providerID", - "modelID" - ], - "additionalProperties": false - }, - "system": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "type": "boolean" - } - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "role", - "time", - "agent", - "model" - ], - "additionalProperties": false - }, - "ProviderAuthError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "ProviderAuthError" - ] - }, - "data": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": [ - "providerID", - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "UnknownError1": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "UnknownError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "ref": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "MessageOutputLengthError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "MessageOutputLengthError" - ] - }, - "data": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "array" - } - ] - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "MessageAbortedError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "MessageAbortedError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "StructuredOutputError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "StructuredOutputError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "retries": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "message", - "retries" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "ContextOverflowError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "ContextOverflowError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "responseBody": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "ContentFilterError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "ContentFilterError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "APIError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "APIError" - ] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "statusCode": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - }, - "isRetryable": { - "type": "boolean" - }, - "responseHeaders": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ] - }, - "responseBody": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "metadata": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "message", - "isRetryable" - ], - "additionalProperties": false - } - }, - "required": [ - "name", - "data" - ], - "additionalProperties": false - }, - "SessionV1.AssistantMessage": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "role": { - "type": "string", - "enum": [ - "assistant" - ] - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "completed": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "created" - ], - "additionalProperties": false - }, - "error": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/components/schemas/ProviderAuthError" - }, - { - "$ref": "#/components/schemas/UnknownError1" - }, - { - "$ref": "#/components/schemas/MessageOutputLengthError" - }, - { - "$ref": "#/components/schemas/MessageAbortedError" - }, - { - "$ref": "#/components/schemas/StructuredOutputError" - }, - { - "$ref": "#/components/schemas/ContextOverflowError" - }, - { - "$ref": "#/components/schemas/ContentFilterError" - }, - { - "$ref": "#/components/schemas/APIError" - } - ] - }, - { - "type": "null" - } - ] - }, - "parentID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "modelID": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "mode": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "path": { - "type": "object", - "properties": { - "cwd": { - "type": "string" - }, - "root": { - "type": "string" - } - }, - "required": [ - "cwd", - "root" - ], - "additionalProperties": false - }, - "summary": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "total": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false - }, - "structured": { - "anyOf": [ - {}, - { - "type": "null" - } - ] - }, - "variant": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "finish": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "role", - "time", - "parentID", - "modelID", - "providerID", - "mode", - "agent", - "path", - "cost", - "tokens" - ], - "additionalProperties": false - }, - "SessionV1.Message": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.UserMessage" - }, - { - "$ref": "#/components/schemas/SessionV1.AssistantMessage" - } - ] - }, - "message.updated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "message.updated" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "info": { - "$ref": "#/components/schemas/SessionV1.Message" - } - }, - "required": [ - "sessionID", - "info" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "message.removed": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "message.removed" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - } - }, - "required": [ - "sessionID", - "messageID" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "SessionV1.TextPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "text" - ] - }, - "text": { - "type": "string" - }, - "synthetic": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "ignored": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "time": { - "anyOf": [ - { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "start" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text" - ], - "additionalProperties": false - }, - "SessionV1.SubtaskPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "subtask" - ] - }, - "prompt": { - "type": "string" - }, - "description": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "anyOf": [ - { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": [ - "providerID", - "modelID" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "command": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "prompt", - "description", - "agent" - ], - "additionalProperties": false - }, - "SessionV1.ReasoningPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "reasoning" - ] - }, - "text": { - "type": "string" - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "start" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text", - "time" - ], - "additionalProperties": false - }, - "SessionV1.FilePartSourceText": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "start": { - "type": "number" - }, - "end": { - "type": "number" - } - }, - "required": [ - "value", - "start", - "end" - ], - "additionalProperties": false - }, - "SessionV1.FileSource": { - "type": "object", - "properties": { - "text": { - "$ref": "#/components/schemas/SessionV1.FilePartSourceText" - }, - "type": { - "type": "string", - "enum": [ - "file" - ] - }, - "path": { - "type": "string" - } - }, - "required": [ - "text", - "type", - "path" - ], - "additionalProperties": false - }, - "SessionV1.Range": { - "type": "object", - "properties": { - "start": { - "type": "object", - "properties": { - "line": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "character": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "line", - "character" - ], - "additionalProperties": false - }, - "end": { - "type": "object", - "properties": { - "line": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "character": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "line", - "character" - ], - "additionalProperties": false - } - }, - "required": [ - "start", - "end" - ], - "additionalProperties": false - }, - "SessionV1.SymbolSource": { - "type": "object", - "properties": { - "text": { - "$ref": "#/components/schemas/SessionV1.FilePartSourceText" - }, - "type": { - "type": "string", - "enum": [ - "symbol" - ] - }, - "path": { - "type": "string" - }, - "range": { - "$ref": "#/components/schemas/SessionV1.Range" - }, - "name": { - "type": "string" - }, - "kind": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "text", - "type", - "path", - "range", - "name", - "kind" - ], - "additionalProperties": false - }, - "SessionV1.ResourceSource": { - "type": "object", - "properties": { - "text": { - "$ref": "#/components/schemas/SessionV1.FilePartSourceText" - }, - "type": { - "type": "string", - "enum": [ - "resource" - ] - }, - "clientName": { - "type": "string" - }, - "uri": { - "type": "string" - } - }, - "required": [ - "text", - "type", - "clientName", - "uri" - ], - "additionalProperties": false - }, - "SessionV1.FilePartSource": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.FileSource" - }, - { - "$ref": "#/components/schemas/SessionV1.SymbolSource" - }, - { - "$ref": "#/components/schemas/SessionV1.ResourceSource" - } - ] - }, - "SessionV1.FilePart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "file" - ] - }, - "mime": { - "type": "string" - }, - "filename": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "url": { - "type": "string" - }, - "source": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.FilePartSource" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "mime", - "url" - ], - "additionalProperties": false - }, - "SessionV1.ToolStatePending": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "pending" - ] - }, - "input": { - "type": "object" - }, - "raw": { - "type": "string" - } - }, - "required": [ - "status", - "input", - "raw" - ], - "additionalProperties": false - }, - "SessionV1.ToolStateRunning": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "running" - ] - }, - "input": { - "type": "object" - }, - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "start" - ], - "additionalProperties": false - } - }, - "required": [ - "status", - "input", - "time" - ], - "additionalProperties": false - }, - "SessionV1.ToolStateCompleted": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "completed" - ] - }, - "input": { - "type": "object" - }, - "output": { - "type": "string" - }, - "title": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "compacted": { - "anyOf": [ - { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "start", - "end" - ], - "additionalProperties": false - }, - "attachments": { - "anyOf": [ - { - "type": "array", - "items": { - "$ref": "#/components/schemas/SessionV1.FilePart" - } - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "status", - "input", - "output", - "title", - "metadata", - "time" - ], - "additionalProperties": false - }, - "SessionV1.ToolStateError": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "error" - ] - }, - "input": { - "type": "object" - }, - "error": { - "type": "string" - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "start", - "end" - ], - "additionalProperties": false - } - }, - "required": [ - "status", - "input", - "error", - "time" - ], - "additionalProperties": false - }, - "SessionV1.ToolState": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.ToolStatePending" - }, - { - "$ref": "#/components/schemas/SessionV1.ToolStateRunning" - }, - { - "$ref": "#/components/schemas/SessionV1.ToolStateCompleted" - }, - { - "$ref": "#/components/schemas/SessionV1.ToolStateError" - } - ] - }, - "SessionV1.ToolPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "tool" - ] - }, - "callID": { - "type": "string" - }, - "tool": { - "type": "string" - }, - "state": { - "$ref": "#/components/schemas/SessionV1.ToolState" - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "callID", - "tool", - "state" - ], - "additionalProperties": false - }, - "SessionV1.StepStartPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "step-start" - ] - }, - "snapshot": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type" - ], - "additionalProperties": false - }, - "SessionV1.StepFinishPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "step-finish" - ] - }, - "reason": { - "type": "string" - }, - "snapshot": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "total": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "reason", - "cost", - "tokens" - ], - "additionalProperties": false - }, - "SessionV1.SnapshotPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "snapshot" - ] - }, - "snapshot": { - "type": "string" - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "snapshot" - ], - "additionalProperties": false - }, - "SessionV1.PatchPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "patch" - ] - }, - "hash": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "hash", - "files" - ], - "additionalProperties": false - }, - "SessionV1.AgentPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "agent" - ] - }, - "name": { - "type": "string" - }, - "source": { - "anyOf": [ - { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "start": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "end": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "value", - "start", - "end" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "name" - ], - "additionalProperties": false - }, - "SessionV1.RetryPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "retry" - ] - }, - "attempt": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "error": { - "$ref": "#/components/schemas/APIError" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "created" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "attempt", - "error", - "time" - ], - "additionalProperties": false - }, - "SessionV1.CompactionPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "type": { - "type": "string", - "enum": [ - "compaction" - ] - }, - "auto": { - "type": "boolean" - }, - "overflow": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "tail_start_id": { - "anyOf": [ - { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "auto" - ], - "additionalProperties": false - }, - "SessionV1.Part": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionV1.TextPart" - }, - { - "$ref": "#/components/schemas/SessionV1.SubtaskPart" - }, - { - "$ref": "#/components/schemas/SessionV1.ReasoningPart" - }, - { - "$ref": "#/components/schemas/SessionV1.FilePart" - }, - { - "$ref": "#/components/schemas/SessionV1.ToolPart" - }, - { - "$ref": "#/components/schemas/SessionV1.StepStartPart" - }, - { - "$ref": "#/components/schemas/SessionV1.StepFinishPart" - }, - { - "$ref": "#/components/schemas/SessionV1.SnapshotPart" - }, - { - "$ref": "#/components/schemas/SessionV1.PatchPart" - }, - { - "$ref": "#/components/schemas/SessionV1.AgentPart" - }, - { - "$ref": "#/components/schemas/SessionV1.RetryPart" - }, - { - "$ref": "#/components/schemas/SessionV1.CompactionPart" - } - ] - }, - "message.part.updated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "message.part.updated" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "part": { - "$ref": "#/components/schemas/SessionV1.Part" - }, - "time": { - "type": "number" - } - }, - "required": [ - "sessionID", - "part", - "time" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, - "message.part.removed": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "message.part.removed" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "number", - "enum": [ - 1 - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg" - } - ] - }, - "partID": { - "type": "string", - "allOf": [ - { - "pattern": "^prt" - } - ] - } - }, - "required": [ - "sessionID", - "messageID", - "partID" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "durable", - "data" - ], - "additionalProperties": false - }, "session.usage.updated": { "type": "object", "properties": { @@ -25596,7 +22783,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "delta": { @@ -25606,7 +22793,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "delta" ], "additionalProperties": false @@ -25665,7 +22852,7 @@ } ] }, - "callID": { + "id": { "type": "string" }, "metadata": { @@ -25675,7 +22862,7 @@ "required": [ "sessionID", "assistantMessageID", - "callID", + "id", "metadata" ], "additionalProperties": false @@ -26757,13 +23944,13 @@ "messageID": { "type": "string" }, - "callID": { + "id": { "type": "string" } }, "required": [ "messageID", - "callID" + "id" ], "additionalProperties": false }, @@ -28442,97 +25629,6 @@ ], "additionalProperties": false }, - "session.error": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.error" - ] - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "anyOf": [ - { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - { - "type": "null" - } - ] - }, - "error": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/components/schemas/ProviderAuthError" - }, - { - "$ref": "#/components/schemas/UnknownError1" - }, - { - "$ref": "#/components/schemas/MessageOutputLengthError" - }, - { - "$ref": "#/components/schemas/MessageAbortedError" - }, - { - "$ref": "#/components/schemas/StructuredOutputError" - }, - { - "$ref": "#/components/schemas/ContextOverflowError" - }, - { - "$ref": "#/components/schemas/ContentFilterError" - }, - { - "$ref": "#/components/schemas/APIError" - } - ] - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "data" - ], - "additionalProperties": false - }, "V2Event.server.connected": { "type": "object", "properties": { @@ -28608,24 +25704,6 @@ { "$ref": "#/components/schemas/session.created" }, - { - "$ref": "#/components/schemas/session.updated" - }, - { - "$ref": "#/components/schemas/session.deleted1" - }, - { - "$ref": "#/components/schemas/message.updated" - }, - { - "$ref": "#/components/schemas/message.removed" - }, - { - "$ref": "#/components/schemas/message.part.updated" - }, - { - "$ref": "#/components/schemas/message.part.removed" - }, { "$ref": "#/components/schemas/session.agent.selected" }, @@ -28860,9 +25938,6 @@ { "$ref": "#/components/schemas/mcp.resources.changed" }, - { - "$ref": "#/components/schemas/session.error" - }, { "$ref": "#/components/schemas/V2Event.server.connected" } @@ -29490,6 +26565,9 @@ { "name": "debug" }, + { + "name": "migration" + }, { "name": "websearch", "description": "Location-scoped web search routes."