mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 16:41:01 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecf5ad692d | |||
| cc12884986 | |||
| e070eda568 | |||
| ec49161441 | |||
| ecf9d3c04c | |||
| 624fc21b32 |
@@ -5,8 +5,27 @@
|
||||
- Use the `dev` branch database schema and migration registry as the V1 baseline.
|
||||
- Remove migrations that exist only on the V2 branch.
|
||||
- Generate one canonical migration from the `dev` schema to the final V2 schema.
|
||||
- Add explicit data operations to that migration where generated DDL is insufficient.
|
||||
- Test the migration against a populated database at the exact `dev` schema.
|
||||
- Keep the canonical migration focused on schema changes and dropping obsolete tables.
|
||||
- Run the V1 history backfill through an experimental server endpoint invoked by the CLI before it opens the TUI.
|
||||
- Show committed session progress while the endpoint runs.
|
||||
|
||||
Expose `GET /api/experimental/migration/v1` for status and a blocking `POST /api/experimental/migration/v1` to run or
|
||||
resume the backfill. The status is `required`, `running`, or `completed`. On startup, the CLI checks status first and
|
||||
renders no migration UI when it is already complete. For required or running status, it shows a spinner and waits for the
|
||||
blocking POST without a request timeout. While migration runs, poll GET once per second and render completed and total
|
||||
session counts. GET derives total from all session rows and completed from rows through the stored cursor; the count
|
||||
advances only after a session transaction commits. The POST returns `{ status: "completed" }`. Do not add a background
|
||||
job or streaming progress protocol. Interrupted calls resume from the stored cursor.
|
||||
Initially, only interactive TUI startup performs this check; noninteractive run, ACP, raw API, service, health, version,
|
||||
and help flows do not trigger the backfill.
|
||||
|
||||
Keep migration behavior in Core: status, semaphore, checkpointing, V1 decoding, transformation, and database writes.
|
||||
Protocol owns the experimental GET/POST contracts, Server handlers delegate to Core, and the interactive CLI owns only
|
||||
the status check and spinner presentation.
|
||||
|
||||
Guard the endpoint with one process-local Effect `Semaphore`. Concurrent callers wait; after the active call completes,
|
||||
waiting callers acquire the permit, observe the completion key, and return immediately. No distributed lock is required
|
||||
for the current single elected server process.
|
||||
|
||||
## Preserve
|
||||
|
||||
@@ -15,20 +34,35 @@ The canonical V1 data remains in its existing tables. In particular, preserve `s
|
||||
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
|
||||
workspace relationships.
|
||||
|
||||
Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the
|
||||
generated migration must not drop the table.
|
||||
Preserve existing non-null `session.agent` and `session.model` selections. Fill missing values from the latest ordinary
|
||||
V1 user message ordered by `time_created` and `id`, excluding compaction and subtask-only messages. Copy agent, provider
|
||||
ID, model ID, and variant, normalizing an absent variant to `default`.
|
||||
|
||||
## Truncate
|
||||
Recompute session usage aggregates from all canonical V1 assistant messages, including compaction or other internal
|
||||
assistants omitted from the V2 projection. Overwrite session cost and input, output, reasoning, cache-read, and
|
||||
cache-write token totals with those sums.
|
||||
|
||||
Truncate these pre-launch V2 tables before applying schema changes:
|
||||
Clear persisted `session.revert` state. A staged revert is transient operational state and may refer to omitted projection
|
||||
rows or unavailable snapshots; it must not resume automatically after upgrading. Preserve the underlying messages,
|
||||
parts, and file history.
|
||||
|
||||
- `event`
|
||||
- `event_sequence`
|
||||
- `session_message`
|
||||
Clear `session.time_compacting`, leave the new `time_suspended` column as `NULL`, and preserve session creation, update,
|
||||
and archive timestamps. Preserve project `time_initialized`; it is unrelated durable state.
|
||||
|
||||
These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the
|
||||
column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message`
|
||||
and `part` rows rather than retaining its pre-launch V2 contents.
|
||||
Keep the legacy `todo` table and its data physically unchanged, but do not include it in the final V2 Drizzle schema.
|
||||
After generation, remove the generated `DROP TABLE todo` statement from the canonical migration so the table remains as
|
||||
unmanaged legacy storage.
|
||||
|
||||
## Per-Session Replacement
|
||||
|
||||
Do not truncate `event`, `event_sequence`, or `session_message` globally before the backfill. A whole-table delete can
|
||||
hold SQLite's writer lock long enough to block the running TUI.
|
||||
|
||||
Replace each legacy session's V2 state inside that session's checkpointed migration transaction. Delete `event` rows for
|
||||
the session aggregate, delete its `session_message` rows, rebuild its projection from canonical V1 `message` and `part`
|
||||
rows, and overwrite its `event_sequence` watermark. If migration of that session fails, all replacements roll back and
|
||||
the durable cursor remains at the previously committed session. Rows owned by sessions outside the legacy migration set
|
||||
remain untouched.
|
||||
|
||||
## Message Backfill
|
||||
|
||||
@@ -36,9 +70,23 @@ Backfill canonical V1 history from `message` and `part` into `session_message`.
|
||||
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
|
||||
V2 session APIs, which read `session_message`.
|
||||
|
||||
Do not fail the whole migration when a V1 message or part payload cannot be decoded. Skip an undecodable message's V2
|
||||
projection and log its session and message IDs. Skip an undecodable part while continuing to map its message, and perform
|
||||
special-message pairing only with decoded rows. Assign sequences after filtering. Leave every malformed source row
|
||||
untouched in the V1 tables.
|
||||
|
||||
Skip and log orphan parts whose source message does not exist and parts with unknown or unsupported types. Continue
|
||||
migrating the owning message and other valid parts. Include session, message, part ID, and observed type in warnings, and
|
||||
leave skipped source rows unchanged.
|
||||
|
||||
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
|
||||
avoid rewriting other persisted state that may refer to a message.
|
||||
|
||||
For ordinary user and assistant rows, preserve source `message.time_created` and `message.time_updated`. Entirely
|
||||
synthetic messages preserve their source timestamps, and synthetic rows split from mixed messages use the source user
|
||||
timestamps. A collapsed compaction uses the compaction user creation time and the later update time of the compaction
|
||||
user and summary assistant. Keep payload creation/completion times consistent with row timestamps.
|
||||
|
||||
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
|
||||
contiguous `session_message.seq` values starting at `0`.
|
||||
|
||||
@@ -46,15 +94,122 @@ Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becom
|
||||
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
|
||||
payload.
|
||||
|
||||
Keep ordinary messages even when their transformed payload becomes empty after filtering. Preserve an empty V2 user row
|
||||
with `text: ""` and an empty V2 assistant row with `content: []` so IDs, chronology, and conversation structure remain
|
||||
stable. Omit only explicitly dropped internal concepts and undecodable messages.
|
||||
|
||||
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
|
||||
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
|
||||
part mappings must be decided explicitly before implementing the backfill.
|
||||
|
||||
Do not carry the V1 subtask concept into the V2 projection. Omit user messages containing only `subtask` parts and omit
|
||||
the paired assistant task-tool messages generated from those markers. For mixed user messages, ignore the `subtask`
|
||||
parts while preserving ordinary content, and still omit assistant task-tool messages generated by the skipped subtasks.
|
||||
Keep all source rows unchanged in the V1 `message` and `part` tables.
|
||||
|
||||
Map ordinary V1 assistant `text` and `reasoning` parts into the V2 assistant `content` array in part order. Preserve text,
|
||||
including empty assistant text parts used as structural separators. Map V1 part metadata to optional V2 provider state.
|
||||
For reasoning, map `time.start` to `time.created` and optional `time.end` to `time.completed`.
|
||||
|
||||
Preserve V1 tool parts that are `pending` or `running`, but convert them to terminal V2 tool error states. Preserve the
|
||||
call ID, tool name, parsed input, metadata, and available start time. Use the assistant message creation time when the V1
|
||||
state has no start time. Set the error to type `tool.interrupted` with message
|
||||
`Tool execution was interrupted before V2 migration`. Never resume migrated tool executions.
|
||||
|
||||
For a completed V1 tool part, use `callID` as the V2 tool content ID and preserve the tool name and parsed input. Set the
|
||||
state to `completed`. Convert V1 output into the first text content item and convert stored output attachments into
|
||||
following file content items with their URI, MIME type, and filename. Preserve state metadata. Map `time.start` to
|
||||
`time.created` and `time.end` to `time.completed`. When `time.compacted` exists, use
|
||||
`[Old tool result content cleared]` as the only output and omit attachments.
|
||||
|
||||
For a failed V1 tool part, preserve the call ID, tool name, parsed input, metadata, and timestamps, and set the V2 state
|
||||
to `error`. Convert the V1 error string to a structured error with type `tool.execution`. If V1 metadata contains a string
|
||||
`output`, preserve it as optional V2 text content. Map `time.start` to `time.created` and `time.end` to `time.completed`.
|
||||
|
||||
For an ordinary V1 assistant message, preserve agent, provider ID, model ID, optional variant, creation and completion
|
||||
times, cost, and input/output/reasoning/cache token counts. Use `default` when the V1 variant is absent. Ignore V1
|
||||
`tokens.total` because it is derivable and V2 does not persist it.
|
||||
|
||||
Use V1 assistant `parentID` only while pairing compactions and skipped subtasks with their originating user messages. Do
|
||||
not persist it in ordinary V2 assistant rows; V2 uses ordered history rather than user/assistant parent links.
|
||||
|
||||
Ignore the optional V1 assistant `structured` output value. V2 has no equivalent top-level assistant field, and visible
|
||||
text and tool content are migrated separately. Retain the original structured value only in the V1 `message` row.
|
||||
|
||||
Ignore V1 assistant `mode` and historical `path` (`cwd` and `root`). Mode is redundant with the preserved assistant
|
||||
agent, and historical filesystem paths do not belong to the V2 assistant message contract. Retain them only in the V1
|
||||
`message` row.
|
||||
|
||||
For assistant finish reasons, preserve `stop`, `length`, `tool-calls`, `content-filter`, `error`, and `unknown`. Map every
|
||||
other nonempty V1 finish value to `unknown`, and leave the field absent when V1 omitted it. Do not retain unrecognized raw
|
||||
finish values in metadata.
|
||||
|
||||
Map V1 assistant errors into the current V2 `{ type, message }` storage shape. Normalize Auth, content-filter, context
|
||||
overflow, structured-output, output-length, aborted, API, and unknown errors to the established V2 string conventions,
|
||||
preserve the message, and discard V1-only retryability and raw provider details.
|
||||
|
||||
Ignore V1 `retry` parts. Do not populate the V2 assistant `retry` field during migration; historical retry state is not
|
||||
useful enough to preserve. The original retry rows remain in the V1 `part` table.
|
||||
|
||||
Do not emit V2 assistant content for V1 `step-start` and `step-finish` parts. Use the first available
|
||||
`step-start.snapshot` as `assistant.snapshot.start` and the last available `step-finish.snapshot` as
|
||||
`assistant.snapshot.end`. Continue to source finish, cost, and tokens from the assistant message itself. Ignore step
|
||||
markers without snapshots.
|
||||
|
||||
Do not emit assistant content for standalone V1 `snapshot` or `patch` parts. If no start snapshot came from `step-start`,
|
||||
use the first standalone snapshot value, then the first patch hash as a final fallback. Only `step-finish.snapshot` may
|
||||
populate the end snapshot. Merge patch file lists into `assistant.snapshot.files` in first-seen order with duplicates
|
||||
removed.
|
||||
|
||||
V2 follow-up: replace the open `SessionError.Error` string shape with a properly typed persisted error union. This is not
|
||||
a blocker for the V1 migration, which should target the current storage contract.
|
||||
|
||||
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
|
||||
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
|
||||
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
|
||||
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
|
||||
|
||||
For an ordinary V2 user message, take visible V1 text parts that are neither ignored nor synthetic, preserve part order,
|
||||
and join their text with `"\n\n"`. Use an empty string when the message contains attachments but no ordinary text.
|
||||
|
||||
Ignore the optional V1 user-message `system` override. Do not create a V2 system message or preserve the override in
|
||||
metadata. The original value remains in the V1 `message` row.
|
||||
|
||||
Ignore the optional V1 user-message `tools` map. It represented request-time tool enablement for a historical step and
|
||||
must not affect future V2 execution. The original value remains in the V1 `message` row.
|
||||
|
||||
Ignore the optional V1 user-message `format` field and its schema. It controlled structured-output behavior for a
|
||||
historical request and must not affect future V2 runs. Preserve visible assistant text normally; retain the original
|
||||
format only in the V1 `message` row.
|
||||
|
||||
Ignore V1 user-message `summary` metadata, including title, body, and diffs. V2 user messages have no equivalent field,
|
||||
and session-level summary data is already persisted separately. Retain the original summary only in the V1 `message`
|
||||
row.
|
||||
|
||||
Map V1 `agent` parts into the V2 user message's `agents` array in part order. Preserve `name`. When the V1 part has
|
||||
`source`, map its `value`, `start`, and `end` into the V2 attachment's `mention.text`, `mention.start`, and `mention.end`.
|
||||
Omit `agents` when there are no agent parts.
|
||||
|
||||
Do not read the filesystem or network while migrating V1 file attachments. Attachment migration must be deterministic
|
||||
from database contents alone. Convert persisted `data:` URLs; represent non-embedded `file:`, HTTP, and other external
|
||||
URLs with deterministic text rather than fetching them. Keep the original V1 `part` rows unchanged.
|
||||
|
||||
For a V1 file backed by a `data:` URL, decode the URL and normalize its payload to base64 for the V2 attachment's `data`.
|
||||
Preserve `mime` and optional `filename` as `name`. Use a V2 `uri` source with the original URI for a V1 resource source;
|
||||
otherwise use an `inline` source. When V1 source text metadata exists, map its `value`, `start`, and `end` into the V2
|
||||
attachment mention. Leave `description` unset and preserve file-part order in the V2 `files` array.
|
||||
|
||||
For a non-embedded V1 file, do not create a V2 file attachment. Append
|
||||
`[Attachment unavailable after migration: <name-or-url> (<mime>)]` to the V2 user text in original part order, separated
|
||||
by blank lines. Prefer the V1 filename, then resource URI, then part URL for the label. The original URL remains only in
|
||||
the preserved V1 `part` row.
|
||||
|
||||
For a synthetic row split from a mixed user message, derive a generated-looking ID from the source message ID. Preserve
|
||||
the source ID's 12-character timestamp component and replace its 14-character random component with a deterministic
|
||||
base-62 encoding of a hash of `v1-synthetic:` plus the source message ID. If that candidate collides with an existing or
|
||||
derived message ID, deterministically retry with an incrementing salt. Place the synthetic row immediately after its
|
||||
source user row. Entirely synthetic messages continue to reuse their original message ID.
|
||||
|
||||
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
|
||||
admitted compaction input ID and preserves references to the initiating message.
|
||||
|
||||
@@ -64,9 +219,13 @@ serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an
|
||||
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
|
||||
assistant row.
|
||||
|
||||
After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that
|
||||
session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting
|
||||
before migrated history. The `event` table remains empty.
|
||||
Do not project incomplete or failed V1 compactions into `session_message`. Omit both the internal compaction user marker
|
||||
and its paired summary assistant when no successful summary was completed. Assign final sequence numbers after filtering
|
||||
so omitted compactions leave no gaps. Their source rows remain preserved in the V1 `message` and `part` tables.
|
||||
|
||||
After rebuilding a session's `session_message`, replace its `event_sequence` watermark with that session's maximum
|
||||
backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting before migrated
|
||||
history. The migrated session's prior `event` rows are removed in the same transaction.
|
||||
|
||||
## Drop
|
||||
|
||||
@@ -74,6 +233,7 @@ Drop these pre-launch V2 tables without preserving or transforming their rows:
|
||||
|
||||
- `session_input`
|
||||
- `session_context_epoch`
|
||||
- `data_migration`
|
||||
|
||||
Do not transfer `session_input` rows into `session_pending`.
|
||||
|
||||
@@ -103,16 +263,34 @@ 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.
|
||||
|
||||
Process every `session` row, including archived, root, child, and empty sessions, as well as sessions whose messages are
|
||||
all skipped or internal. Each successfully committed session advances the cursor.
|
||||
|
||||
## Testing
|
||||
|
||||
Detailed migration test design is deferred until after the canonical migration is implemented.
|
||||
|
||||
@@ -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",
|
||||
}))
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }))
|
||||
|
||||
@@ -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,33 @@ export interface DebugApi<E = never> {
|
||||
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint27_0Input = {
|
||||
export type Endpoint27_0Output = {
|
||||
readonly status: "required" | "running" | "completed"
|
||||
readonly completed: number
|
||||
readonly total: number
|
||||
}
|
||||
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
|
||||
|
||||
export type Endpoint27_1Output = { readonly status: "completed" }
|
||||
export type MigrationV1RunOperation<E = never> = () => Effect.Effect<Endpoint27_1Output, E>
|
||||
|
||||
export interface MigrationApi<E = never> {
|
||||
readonly v1: { readonly status: MigrationV1StatusOperation<E>; readonly run: MigrationV1RunOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint28_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
|
||||
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
||||
|
||||
export type Endpoint27_1Input = {
|
||||
export type Endpoint28_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly query: string
|
||||
readonly providerID?: WebSearch.ID | undefined
|
||||
}
|
||||
export type Endpoint27_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
|
||||
export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint28_1Input) => Effect.Effect<Endpoint28_1Output, E>
|
||||
|
||||
export interface WebsearchApi<E = never> {
|
||||
readonly providers: WebsearchProvidersOperation<E>
|
||||
@@ -1586,5 +1620,6 @@ export interface AppApi<E = never> {
|
||||
readonly projectCopy: ProjectCopyApi<E>
|
||||
readonly vcs: VcsApi<E>
|
||||
readonly debug: DebugApi<E>
|
||||
readonly migration: MigrationApi<E>
|
||||
readonly websearch: WebsearchApi<E>
|
||||
}
|
||||
|
||||
@@ -215,10 +215,12 @@ import type {
|
||||
Endpoint26_0Output,
|
||||
Endpoint26_1Input,
|
||||
Endpoint26_1Output,
|
||||
Endpoint27_0Input,
|
||||
Endpoint27_0Output,
|
||||
Endpoint27_1Input,
|
||||
Endpoint27_1Output,
|
||||
Endpoint28_0Input,
|
||||
Endpoint28_0Output,
|
||||
Endpoint28_1Input,
|
||||
Endpoint28_1Output,
|
||||
} from "../api/api.js"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -1217,22 +1219,32 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
|
||||
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
|
||||
})
|
||||
|
||||
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
|
||||
preserveEffect<Endpoint27_0Output>()(
|
||||
const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
|
||||
preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const Endpoint27_1 = (raw: RawClient["server.migration"]) => () =>
|
||||
preserveEffect<Endpoint27_1Output>()(raw["migration.v1.run"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({
|
||||
v1: { status: Endpoint27_0(raw), run: Endpoint27_1(raw) },
|
||||
})
|
||||
|
||||
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
|
||||
preserveEffect<Endpoint28_0Output>()(
|
||||
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
|
||||
preserveEffect<Endpoint27_1Output>()(
|
||||
const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
|
||||
preserveEffect<Endpoint28_1Output>()(
|
||||
raw["websearch.query"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { query: input["query"], providerID: input["providerID"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint27_0(raw),
|
||||
query: Endpoint27_1(raw),
|
||||
const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint28_0(raw),
|
||||
query: Endpoint28_1(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
@@ -1263,7 +1275,8 @@ const adaptClient = (raw: RawClient) => ({
|
||||
projectCopy: adaptGroup24(raw["server.projectCopy"]),
|
||||
vcs: adaptGroup25(raw["server.vcs"]),
|
||||
debug: adaptGroup26(raw["server.debug"]),
|
||||
websearch: adaptGroup27(raw["server.websearch"]),
|
||||
migration: adaptGroup27(raw["server.migration"]),
|
||||
websearch: adaptGroup28(raw["server.websearch"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
||||
@@ -211,6 +211,8 @@ import type {
|
||||
DebugLocationListOutput,
|
||||
DebugLocationEvictInput,
|
||||
DebugLocationEvictOutput,
|
||||
MigrationV1StatusOutput,
|
||||
MigrationV1RunOutput,
|
||||
WebsearchProvidersInput,
|
||||
WebsearchProvidersOutput,
|
||||
WebsearchQueryInput,
|
||||
@@ -492,7 +494,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -718,7 +720,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/context`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 500, 400, 401],
|
||||
declaredStatuses: [404, 500, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -730,7 +732,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -793,7 +795,7 @@ export function make(options: ClientOptions) {
|
||||
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/log`,
|
||||
query: { after: input["after"], follow: input["follow"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -826,7 +828,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -1768,6 +1770,32 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
},
|
||||
migration: {
|
||||
v1: {
|
||||
status: (requestOptions?: RequestOptions) =>
|
||||
request<MigrationV1StatusOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/experimental/migration/v1`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
run: (requestOptions?: RequestOptions) =>
|
||||
request<MigrationV1RunOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/migration/v1`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
providers: (input?: WebsearchProvidersInput, requestOptions?: RequestOptions) =>
|
||||
request<WebsearchProvidersOutput>(
|
||||
|
||||
@@ -313,164 +313,6 @@ export type SkillInfo = {
|
||||
content: string
|
||||
}
|
||||
|
||||
export type FileDiffLegacyInfo = {
|
||||
file?: string
|
||||
patch?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type PermissionV1Action = "allow" | "deny" | "ask"
|
||||
|
||||
export type SessionV1JSONSchema = { [x: string]: any }
|
||||
|
||||
export type ProviderAuthError = { name: "ProviderAuthError"; data: { providerID: string; message: string } }
|
||||
|
||||
export type UnknownError2 = { name: "UnknownError"; data: { message: string; ref?: string | undefined } }
|
||||
|
||||
export type MessageOutputLengthError = { name: "MessageOutputLengthError"; data: {} }
|
||||
|
||||
export type MessageAbortedError = { name: "MessageAbortedError"; data: { message: string } }
|
||||
|
||||
export type StructuredOutputError = { name: "StructuredOutputError"; data: { message: string; retries: number } }
|
||||
|
||||
export type ContextOverflowError = {
|
||||
name: "ContextOverflowError"
|
||||
data: { message: string; responseBody?: string | undefined }
|
||||
}
|
||||
|
||||
export type ContentFilterError = { name: "ContentFilterError"; data: { message: string } }
|
||||
|
||||
export type APIError = {
|
||||
name: "APIError"
|
||||
data: {
|
||||
message: string
|
||||
statusCode?: number | undefined
|
||||
isRetryable: boolean
|
||||
responseHeaders?: { [x: string]: string } | undefined
|
||||
responseBody?: string | undefined
|
||||
metadata?: { [x: string]: string } | undefined
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionV1TextPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: boolean | undefined
|
||||
ignored?: boolean | undefined
|
||||
time?: { start: number; end?: number | undefined } | undefined
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
}
|
||||
|
||||
export type SessionV1SubtaskPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "subtask"
|
||||
prompt: string
|
||||
description: string
|
||||
agent: string
|
||||
model?: { providerID: string; modelID: string } | undefined
|
||||
command?: string | undefined
|
||||
}
|
||||
|
||||
export type SessionV1ReasoningPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "reasoning"
|
||||
text: string
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
time: { start: number; end?: number | undefined }
|
||||
}
|
||||
|
||||
export type SessionV1FilePartSourceText = { value: string; start: number; end: number }
|
||||
|
||||
export type SessionV1Range = { start: { line: number; character: number }; end: { line: number; character: number } }
|
||||
|
||||
export type SessionV1ToolStatePending = { status: "pending"; input: { [x: string]: any }; raw: string }
|
||||
|
||||
export type SessionV1ToolStateRunning = {
|
||||
status: "running"
|
||||
input: { [x: string]: any }
|
||||
title?: string | undefined
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
time: { start: number }
|
||||
}
|
||||
|
||||
export type SessionV1ToolStateError = {
|
||||
status: "error"
|
||||
input: { [x: string]: any }
|
||||
error: string
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
|
||||
export type SessionV1StepStartPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "step-start"
|
||||
snapshot?: string | undefined
|
||||
}
|
||||
|
||||
export type SessionV1StepFinishPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "step-finish"
|
||||
reason: string
|
||||
snapshot?: string | undefined
|
||||
cost: number
|
||||
tokens: {
|
||||
total?: number | undefined
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cache: { read: number; write: number }
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionV1SnapshotPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "snapshot"
|
||||
snapshot: string
|
||||
}
|
||||
|
||||
export type SessionV1PatchPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "patch"
|
||||
hash: string
|
||||
files: Array<string>
|
||||
}
|
||||
|
||||
export type SessionV1AgentPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "agent"
|
||||
name: string
|
||||
source?: { value: string; start: number; end: number } | undefined
|
||||
}
|
||||
|
||||
export type SessionV1CompactionPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "compaction"
|
||||
auto: boolean
|
||||
overflow?: boolean | undefined
|
||||
tail_start_id?: string | undefined
|
||||
}
|
||||
|
||||
export type PermissionReply = "once" | "always" | "reject"
|
||||
|
||||
export type Pty = {
|
||||
@@ -569,6 +411,27 @@ export type ProviderRequest = {
|
||||
|
||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.created"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
projectID: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
parentID?: string
|
||||
slug: string
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionAgentSelected = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -862,26 +725,6 @@ export type AgentUpdated = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type MessageRemoved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "message.removed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; messageID: string }
|
||||
}
|
||||
|
||||
export type MessagePartRemoved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "message.part.removed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; messageID: string; partID: string }
|
||||
}
|
||||
|
||||
export type SessionUsageUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1537,96 +1380,6 @@ export type PermissionAsked = {
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionV1Rule = { permission: string; pattern: string; action: PermissionV1Action }
|
||||
|
||||
export type SessionV1OutputFormat =
|
||||
| { type: "text" }
|
||||
| { type: "json_schema"; schema: SessionV1JSONSchema; retryCount?: number | undefined | undefined }
|
||||
|
||||
export type SessionV1AssistantMessage = {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "assistant"
|
||||
time: { created: number; completed?: number | undefined }
|
||||
error?:
|
||||
| ProviderAuthError
|
||||
| UnknownError2
|
||||
| MessageOutputLengthError
|
||||
| MessageAbortedError
|
||||
| StructuredOutputError
|
||||
| ContextOverflowError
|
||||
| ContentFilterError
|
||||
| APIError
|
||||
| undefined
|
||||
parentID: string
|
||||
modelID: string
|
||||
providerID: string
|
||||
mode: string
|
||||
agent: string
|
||||
path: { cwd: string; root: string }
|
||||
summary?: boolean | undefined
|
||||
cost: number
|
||||
tokens: {
|
||||
total?: number | undefined
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cache: { read: number; write: number }
|
||||
}
|
||||
structured?: any | undefined
|
||||
variant?: string | undefined
|
||||
finish?: string | undefined
|
||||
}
|
||||
|
||||
export type SessionV1RetryPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "retry"
|
||||
attempt: number
|
||||
error: APIError
|
||||
time: { created: number }
|
||||
}
|
||||
|
||||
export type SessionError = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.error"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID?: string | undefined
|
||||
error?:
|
||||
| ProviderAuthError
|
||||
| UnknownError2
|
||||
| MessageOutputLengthError
|
||||
| MessageAbortedError
|
||||
| StructuredOutputError
|
||||
| ContextOverflowError
|
||||
| ContentFilterError
|
||||
| APIError
|
||||
| undefined
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionV1FileSource = { text: SessionV1FilePartSourceText; type: "file"; path: string }
|
||||
|
||||
export type SessionV1ResourceSource = {
|
||||
text: SessionV1FilePartSourceText
|
||||
type: "resource"
|
||||
clientName: string
|
||||
uri: string
|
||||
}
|
||||
|
||||
export type SessionV1SymbolSource = {
|
||||
text: SessionV1FilePartSourceText
|
||||
type: "symbol"
|
||||
path: string
|
||||
range: SessionV1Range
|
||||
name: string
|
||||
kind: number
|
||||
}
|
||||
|
||||
export type PermissionReplied = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1904,23 +1657,6 @@ export type FormReplied = {
|
||||
data: { id: string; sessionID: string; answer: FormAnswer }
|
||||
}
|
||||
|
||||
export type PermissionV1Ruleset = Array<PermissionV1Rule>
|
||||
|
||||
export type SessionV1UserMessage = {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "user"
|
||||
time: { created: number }
|
||||
format?: SessionV1OutputFormat | undefined
|
||||
summary?: { title?: string | undefined; body?: string | undefined; diffs: Array<FileDiffLegacyInfo> } | undefined
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string | undefined }
|
||||
system?: string | undefined
|
||||
tools?: { [x: string]: boolean } | undefined
|
||||
}
|
||||
|
||||
export type SessionV1FilePartSource = SessionV1FileSource | SessionV1SymbolSource | SessionV1ResourceSource
|
||||
|
||||
export type QuestionAsked = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1998,41 +1734,6 @@ export type IntegrationMethod =
|
||||
|
||||
export type FormFields = [FormField, ...Array<FormField>]
|
||||
|
||||
export type SessionV1Info = {
|
||||
id: string
|
||||
slug: string
|
||||
projectID: string
|
||||
workspaceID?: string
|
||||
directory: string
|
||||
path?: string
|
||||
parentID?: string
|
||||
summary?: { additions: number; deletions: number; files: number; diffs?: Array<FileDiffLegacyInfo> }
|
||||
cost?: number
|
||||
tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
|
||||
share?: { url: string }
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
version: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number; updated: number; compacting?: number; archived?: number }
|
||||
permission?: PermissionV1Ruleset
|
||||
revert?: { messageID: string; partID?: string; snapshot?: string; diff?: string }
|
||||
}
|
||||
|
||||
export type SessionV1Message = SessionV1UserMessage | SessionV1AssistantMessage
|
||||
|
||||
export type SessionV1FilePart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "file"
|
||||
mime: string
|
||||
filename?: string | undefined
|
||||
url: string
|
||||
source?: SessionV1FilePartSource | undefined
|
||||
}
|
||||
|
||||
export type FormFields1 = [FormField1, ...Array<FormField1>]
|
||||
|
||||
export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction
|
||||
@@ -2064,56 +1765,6 @@ export type IntegrationInfo = {
|
||||
|
||||
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.created"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; info: SessionV1Info }
|
||||
}
|
||||
|
||||
export type SessionUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; info: SessionV1Info }
|
||||
}
|
||||
|
||||
export type SessionDeleted1 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.deleted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; info: SessionV1Info }
|
||||
}
|
||||
|
||||
export type MessageUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "message.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; info: SessionV1Message }
|
||||
}
|
||||
|
||||
export type SessionV1ToolStateCompleted = {
|
||||
status: "completed"
|
||||
input: { [x: string]: any }
|
||||
output: string
|
||||
title: string
|
||||
metadata: { [x: string]: any }
|
||||
time: { start: number; end: number; compacted?: number | undefined }
|
||||
attachments?: Array<SessionV1FilePart> | undefined
|
||||
}
|
||||
|
||||
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
|
||||
|
||||
export type SessionInputAdmitted = {
|
||||
@@ -2137,12 +1788,6 @@ export type SessionMessageInfo =
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
|
||||
export type SessionV1ToolState =
|
||||
| SessionV1ToolStatePending
|
||||
| SessionV1ToolStateRunning
|
||||
| SessionV1ToolStateCompleted
|
||||
| SessionV1ToolStateError
|
||||
|
||||
export type FormCreated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2153,6 +1798,7 @@ export type FormCreated = {
|
||||
}
|
||||
|
||||
export type SessionEventDurable =
|
||||
| SessionCreated
|
||||
| SessionAgentSelected
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
@@ -2197,43 +1843,6 @@ export type SessionMessagesResponse = {
|
||||
cursor: { previous?: string | null; next?: string | null }
|
||||
}
|
||||
|
||||
export type SessionV1ToolPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: SessionV1ToolState
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
}
|
||||
|
||||
export type SessionLogItem = SessionEventDurable | EventLogSynced
|
||||
|
||||
export type SessionV1Part =
|
||||
| SessionV1TextPart
|
||||
| SessionV1SubtaskPart
|
||||
| SessionV1ReasoningPart
|
||||
| SessionV1FilePart
|
||||
| SessionV1ToolPart
|
||||
| SessionV1StepStartPart
|
||||
| SessionV1StepFinishPart
|
||||
| SessionV1SnapshotPart
|
||||
| SessionV1PatchPart
|
||||
| SessionV1AgentPart
|
||||
| SessionV1RetryPart
|
||||
| SessionV1CompactionPart
|
||||
|
||||
export type MessagePartUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "message.part.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; part: SessionV1Part; time: number }
|
||||
}
|
||||
|
||||
export type V2Event =
|
||||
| ModelsDevRefreshed
|
||||
| IntegrationUpdated
|
||||
@@ -2241,12 +1850,6 @@ export type V2Event =
|
||||
| CatalogUpdated
|
||||
| AgentUpdated
|
||||
| SessionCreated
|
||||
| SessionUpdated
|
||||
| SessionDeleted1
|
||||
| MessageUpdated
|
||||
| MessageRemoved
|
||||
| MessagePartUpdated
|
||||
| MessagePartRemoved
|
||||
| SessionAgentSelected
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
@@ -2325,9 +1928,10 @@ export type V2Event =
|
||||
| VcsBranchUpdated
|
||||
| McpStatusChanged
|
||||
| McpResourcesChanged
|
||||
| SessionError
|
||||
| V2EventServerConnected
|
||||
|
||||
export type SessionLogItem = SessionEventDurable | EventLogSynced
|
||||
|
||||
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
|
||||
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
|
||||
@@ -4961,6 +4565,10 @@ export type DebugLocationEvictInput = {
|
||||
|
||||
export type DebugLocationEvictOutput = void
|
||||
|
||||
export type MigrationV1StatusOutput = { status: "required" | "running" | "completed"; completed: number; total: number }
|
||||
|
||||
export type MigrationV1RunOutput = { status: "completed" }
|
||||
|
||||
export type WebsearchProvidersInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"db": "bun drizzle-kit",
|
||||
"migration": "bun run script/migration.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"benchmark:location": "bun run script/benchmark-location.ts",
|
||||
"test": "bun test --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
|
||||
+58
-399
@@ -1,19 +1,15 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1",
|
||||
"id": "2d214a71-3b0a-48c1-a667-741952c4e188",
|
||||
"prevIds": [
|
||||
"db37a97f-9b5e-4c87-be8b-4feace35136c"
|
||||
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "workspace",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "data_migration",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "account_state",
|
||||
"entityType": "tables"
|
||||
@@ -66,14 +62,6 @@
|
||||
"name": "instruction_state",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "part",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "session_message",
|
||||
"entityType": "tables"
|
||||
@@ -83,11 +71,7 @@
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "session",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "session_share",
|
||||
"name": "session_v2",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
@@ -170,26 +154,6 @@
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "name",
|
||||
"entityType": "columns",
|
||||
"table": "data_migration"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_completed",
|
||||
"entityType": "columns",
|
||||
"table": "data_migration"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
@@ -534,7 +498,7 @@
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "created",
|
||||
"entityType": "columns",
|
||||
@@ -960,116 +924,6 @@
|
||||
"entityType": "columns",
|
||||
"table": "instruction_state"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "message_id",
|
||||
"entityType": "columns",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
@@ -1218,7 +1072,7 @@
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1228,7 +1082,7 @@
|
||||
"generated": null,
|
||||
"name": "project_id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1238,7 +1092,7 @@
|
||||
"generated": null,
|
||||
"name": "workspace_id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1248,7 +1102,7 @@
|
||||
"generated": null,
|
||||
"name": "parent_id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1258,7 +1112,7 @@
|
||||
"generated": null,
|
||||
"name": "fork_session_id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1268,7 +1122,7 @@
|
||||
"generated": null,
|
||||
"name": "fork_boundary",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1278,7 +1132,7 @@
|
||||
"generated": null,
|
||||
"name": "slug",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1288,7 +1142,7 @@
|
||||
"generated": null,
|
||||
"name": "directory",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1298,7 +1152,7 @@
|
||||
"generated": null,
|
||||
"name": "path",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1308,7 +1162,7 @@
|
||||
"generated": null,
|
||||
"name": "title",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1318,7 +1172,7 @@
|
||||
"generated": null,
|
||||
"name": "version",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1328,7 +1182,7 @@
|
||||
"generated": null,
|
||||
"name": "share_url",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1338,7 +1192,7 @@
|
||||
"generated": null,
|
||||
"name": "summary_additions",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1348,7 +1202,7 @@
|
||||
"generated": null,
|
||||
"name": "summary_deletions",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1358,7 +1212,7 @@
|
||||
"generated": null,
|
||||
"name": "summary_files",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1368,7 +1222,7 @@
|
||||
"generated": null,
|
||||
"name": "summary_diffs",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1378,7 +1232,7 @@
|
||||
"generated": null,
|
||||
"name": "metadata",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "real",
|
||||
@@ -1388,7 +1242,7 @@
|
||||
"generated": null,
|
||||
"name": "cost",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1398,7 +1252,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_input",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1408,7 +1262,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_output",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1418,7 +1272,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_reasoning",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1428,7 +1282,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_cache_read",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1438,7 +1292,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_cache_write",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1448,7 +1302,7 @@
|
||||
"generated": null,
|
||||
"name": "revert",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1458,7 +1312,7 @@
|
||||
"generated": null,
|
||||
"name": "permission",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1468,7 +1322,7 @@
|
||||
"generated": null,
|
||||
"name": "agent",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1478,7 +1332,7 @@
|
||||
"generated": null,
|
||||
"name": "model",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1488,7 +1342,7 @@
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1498,7 +1352,7 @@
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1508,7 +1362,7 @@
|
||||
"generated": null,
|
||||
"name": "time_compacting",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1518,7 +1372,7 @@
|
||||
"generated": null,
|
||||
"name": "time_archived",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1528,67 +1382,7 @@
|
||||
"generated": null,
|
||||
"name": "time_suspended",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "session_share"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "session_share"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "secret",
|
||||
"entityType": "columns",
|
||||
"table": "session_share"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "url",
|
||||
"entityType": "columns",
|
||||
"table": "session_share"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "session_share"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "session_share"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -1669,14 +1463,14 @@
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_instruction_entry_session_id_session_id_fk",
|
||||
"name": "fk_instruction_entry_session_id_session_v2_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "instruction_entry"
|
||||
},
|
||||
@@ -1684,14 +1478,14 @@
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_instruction_state_session_id_session_id_fk",
|
||||
"name": "fk_instruction_state_session_id_session_v2_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "instruction_state"
|
||||
},
|
||||
@@ -1699,44 +1493,14 @@
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_message_session_id_session_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"message_id"
|
||||
],
|
||||
"tableTo": "message",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_part_message_id_message_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_message_session_id_session_id_fk",
|
||||
"name": "fk_session_message_session_id_session_v2_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_message"
|
||||
},
|
||||
@@ -1744,14 +1508,14 @@
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_input_session_id_session_id_fk",
|
||||
"name": "fk_session_pending_session_id_session_v2_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_pending"
|
||||
},
|
||||
@@ -1766,24 +1530,9 @@
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_project_id_project_id_fk",
|
||||
"name": "fk_session_v2_project_id_project_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_share_session_id_session_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_share"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -1824,15 +1573,6 @@
|
||||
"table": "workspace",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "data_migration_pk",
|
||||
"table": "data_migration",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
@@ -1923,24 +1663,6 @@
|
||||
"table": "instruction_state",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "message_pk",
|
||||
"table": "message",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "part_pk",
|
||||
"table": "part",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
@@ -1955,7 +1677,7 @@
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_input_pk",
|
||||
"name": "session_pending_pk",
|
||||
"table": "session_pending",
|
||||
"entityType": "pks"
|
||||
},
|
||||
@@ -1964,17 +1686,8 @@
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_pk",
|
||||
"table": "session",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_share_pk",
|
||||
"table": "session_share",
|
||||
"name": "session_v2_pk",
|
||||
"table": "session_v2",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
@@ -2039,60 +1752,6 @@
|
||||
"entityType": "indexes",
|
||||
"table": "permission"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "session_id",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "time_created",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "id",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "message_session_time_created_id_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "message_id",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "id",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "part_message_id_id_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "session_id",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "part_session_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
@@ -2233,9 +1892,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_project_idx",
|
||||
"name": "session_v2_project_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -2247,9 +1906,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_workspace_idx",
|
||||
"name": "session_v2_workspace_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -2261,9 +1920,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_parent_idx",
|
||||
"name": "session_v2_parent_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -2273,11 +1932,11 @@
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"session\".\"time_suspended\" is not null",
|
||||
"where": "\"session_v2\".\"time_suspended\" is not null",
|
||||
"origin": "manual",
|
||||
"name": "session_time_suspended_idx",
|
||||
"name": "session_v2_time_suspended_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import path from "path"
|
||||
import { Effect, Logger } from "effect"
|
||||
import { AppNodeBuilder } from "../src/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Database } from "../src/database/database"
|
||||
import { Bus } from "../src/bus"
|
||||
import { SdkPlugins } from "../src/plugin/sdk"
|
||||
import { Location } from "../src/location"
|
||||
import { LocationServiceMap } from "../src/location-service-map"
|
||||
import { AbsolutePath } from "../src/schema"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const iterationsIndex = args.indexOf("--iterations")
|
||||
const iterations = iterationsIndex === -1 ? 10 : Number(args[iterationsIndex + 1])
|
||||
const directory = args.find((arg, index) => !arg.startsWith("--") && index !== iterationsIndex + 1) ?? process.cwd()
|
||||
|
||||
if (!Number.isInteger(iterations) || iterations < 1) {
|
||||
console.error("--iterations must be a positive integer")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(path.resolve(directory)) })
|
||||
const layer = AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]),
|
||||
)
|
||||
|
||||
const measure = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const start = performance.now()
|
||||
yield* effect
|
||||
return performance.now() - start
|
||||
})
|
||||
|
||||
const stats = (samples: ReadonlyArray<number>) => {
|
||||
const sorted = samples.toSorted((a, b) => a - b)
|
||||
const percentile = (value: number) => sorted[Math.min(Math.ceil(sorted.length * value) - 1, sorted.length - 1)]
|
||||
return {
|
||||
mean: samples.reduce((total, sample) => total + sample, 0) / samples.length,
|
||||
min: sorted[0] ?? 0,
|
||||
p50: percentile(0.5),
|
||||
p95: percentile(0.95),
|
||||
max: sorted.at(-1) ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
const print = (name: string, samples: ReadonlyArray<number>) => {
|
||||
const result = stats(samples)
|
||||
console.log(
|
||||
`${name.padEnd(12)} mean ${result.mean.toFixed(2)} ms min ${result.min.toFixed(2)} ms p50 ${result.p50.toFixed(2)} ms p95 ${result.p95.toFixed(2)} ms max ${result.max.toFixed(2)} ms`,
|
||||
)
|
||||
}
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const load = locations.contextEffect(ref).pipe(Effect.scoped)
|
||||
|
||||
const first = yield* measure(load)
|
||||
const cached = yield* Effect.forEach(Array.from({ length: iterations }), () => measure(load))
|
||||
const cold = yield* Effect.forEach(Array.from({ length: iterations }), () =>
|
||||
locations.invalidate(ref).pipe(Effect.andThen(measure(load))),
|
||||
)
|
||||
|
||||
console.log(`Location: ${ref.directory}`)
|
||||
console.log(`Iterations: ${iterations}`)
|
||||
print("first", [first])
|
||||
print("cached", cached)
|
||||
print("cold", cold)
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.provide(Logger.layer([])))
|
||||
|
||||
await Effect.runPromise(program)
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
@@ -100,9 +99,14 @@ async function drizzle(temporary: string, output: string, name?: string) {
|
||||
export default { ...config, out: ${JSON.stringify(output)} }
|
||||
`,
|
||||
)
|
||||
await $`bun drizzle-kit generate --config ${config} ${name ? ["--name", name] : []}`.cwd(
|
||||
path.join(root, "packages/core"),
|
||||
)
|
||||
const child = Bun.spawn(["bun", "drizzle-kit", "generate", "--config", config, ...(name ? ["--name", name] : [])], {
|
||||
cwd: path.join(root, "packages/core"),
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
const exit = await child.exited
|
||||
if (exit !== 0) throw new Error(`Drizzle generation failed with exit code ${exit}.`)
|
||||
}
|
||||
|
||||
async function generatedMigrations(directory: string) {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
|
||||
export const DataMigrationTable = sqliteTable("data_migration", {
|
||||
name: text().primaryKey(),
|
||||
time_completed: integer().notNull(),
|
||||
})
|
||||
+2
-19
@@ -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[]
|
||||
|
||||
@@ -12,6 +12,7 @@ const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export type Migration = {
|
||||
id: string
|
||||
foreignKeys?: boolean
|
||||
up: (tx: Transaction) => Effect.Effect<void, unknown>
|
||||
}
|
||||
|
||||
@@ -21,8 +22,11 @@ export function apply(db: Database) {
|
||||
const tables = yield* db.all<{ name: string }>(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
|
||||
)
|
||||
if (tables.some((table) => table.name === "session")) return yield* applyOnly(db, migrations)
|
||||
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
|
||||
return yield* applyOnly(db, migrations)
|
||||
if (tables.length > 0) return yield* Effect.die(new Error("Database is not empty and has no session table"))
|
||||
const started = Date.now()
|
||||
yield* Effect.logInfo("database schema bootstrap started", { migrations: migrations.length })
|
||||
yield* db.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* schema.up(tx)
|
||||
@@ -36,6 +40,10 @@ export function apply(db: Database) {
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* Effect.logInfo("database schema bootstrap completed", {
|
||||
migrations: migrations.length,
|
||||
durationMs: Date.now() - started,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -68,7 +76,9 @@ export function applyOnly(db: Database, input: Migration[]) {
|
||||
|
||||
for (const migration of input) {
|
||||
if (completed.has(migration.id)) continue
|
||||
yield* db.transaction((tx) =>
|
||||
const started = Date.now()
|
||||
yield* Effect.logInfo("database migration started", { migration: migration.id })
|
||||
const apply = db.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* migration.up(tx)
|
||||
yield* tx.run(
|
||||
@@ -76,6 +86,37 @@ export function applyOnly(db: Database, input: Migration[]) {
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (migration.foreignKeys !== false) {
|
||||
yield* apply.pipe(
|
||||
Effect.tapError((error) =>
|
||||
Effect.logError("database migration failed", {
|
||||
migration: migration.id,
|
||||
durationMs: Date.now() - started,
|
||||
error,
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.logInfo("database migration completed", {
|
||||
migration: migration.id,
|
||||
durationMs: Date.now() - started,
|
||||
})
|
||||
continue
|
||||
}
|
||||
yield* db.run(sql`PRAGMA foreign_keys = OFF`)
|
||||
yield* apply.pipe(
|
||||
Effect.ensuring(db.run(sql`PRAGMA foreign_keys = ON`).pipe(Effect.orDie)),
|
||||
Effect.tapError((error) =>
|
||||
Effect.logError("database migration failed", {
|
||||
migration: migration.id,
|
||||
durationMs: Date.now() - started,
|
||||
error,
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.logInfo("database migration completed", {
|
||||
migration: migration.id,
|
||||
durationMs: Date.now() - started,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ export default {
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
|
||||
yield* tx.run(`DELETE FROM \`workspace\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260702134641_add_session_context_entry",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`session_context_entry\` (
|
||||
\`session_id\` text NOT NULL,
|
||||
\`key\` text NOT NULL,
|
||||
\`value\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
CONSTRAINT \`session_context_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
|
||||
CONSTRAINT \`fk_session_context_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260703090000_reset_v2_event_rename_sweep",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
// `created` column is added by the generated 20260703181610_event_created_column
|
||||
// migration, which runs after this wipe (NOT NULL without default is safe on the
|
||||
// emptied table).
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260703181610_event_created_column",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`event\` ADD \`created\` integer NOT NULL;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260703190000_reset_v2_shell_event_payloads",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260703200000_reset_v2_session_events",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260705180000_rename_instructions",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_context_entry\` RENAME TO \`instruction_entry\``)
|
||||
yield* tx.run(`ALTER TABLE \`session_context_epoch\` RENAME TO \`instruction_checkpoint\``)
|
||||
yield* tx.run(`
|
||||
UPDATE \`event\`
|
||||
SET \`type\` = 'session.instructions.updated.1'
|
||||
WHERE \`type\` = 'session.context.updated.1'
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260706223930_add-session-fork",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_session_id\` text;`)
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_message_id\` text;`)
|
||||
yield* tx.run(`
|
||||
UPDATE \`session\`
|
||||
SET
|
||||
\`parent_id\` = NULL,
|
||||
\`fork_session_id\` = (
|
||||
SELECT json_extract(\`event\`.\`data\`, '$.parentID')
|
||||
FROM \`event\`
|
||||
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
|
||||
AND \`event\`.\`type\` = 'session.forked'
|
||||
ORDER BY \`event\`.\`seq\`
|
||||
LIMIT 1
|
||||
),
|
||||
\`fork_message_id\` = (
|
||||
SELECT json_extract(\`event\`.\`data\`, '$.from')
|
||||
FROM \`event\`
|
||||
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
|
||||
AND \`event\`.\`type\` = 'session.forked'
|
||||
ORDER BY \`event\`.\`seq\`
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM \`event\`
|
||||
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
|
||||
AND \`event\`.\`type\` = 'session.forked'
|
||||
);
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260707010146_durable_session_inbox",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`__new_session_input\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`prompt\` text,
|
||||
\`delivery\` text,
|
||||
\`admitted_seq\` integer NOT NULL,
|
||||
\`promoted_seq\` integer,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(
|
||||
`INSERT INTO \`__new_session_input\`(\`id\`, \`session_id\`, \`type\`, \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\`) SELECT \`id\`, \`session_id\`, 'prompt', \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\` FROM \`session_input\`;`,
|
||||
)
|
||||
yield* tx.run(`DROP TABLE \`session_input\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`)
|
||||
yield* tx.run(`PRAGMA foreign_keys=ON;`)
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_input_session_pending_delivery_seq_idx\`;`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
|
||||
)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,227 +0,0 @@
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString)
|
||||
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
|
||||
|
||||
export default {
|
||||
id: "20260707120000_migrate_prelaunch_v2_state",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(
|
||||
sql`DELETE FROM session_message WHERE type = 'compaction' AND json_extract(data, '$.status') = 'queued'`,
|
||||
)
|
||||
const messages = yield* tx.all<{ id: string; type: string; data: string }>(
|
||||
sql`SELECT id, type, data FROM session_message WHERE type IN ('skill', 'shell', 'assistant', 'compaction', 'synthetic')`,
|
||||
)
|
||||
for (const row of messages) {
|
||||
const data = object(decodeJson(row.data))
|
||||
yield* tx.run(
|
||||
sql`UPDATE session_message SET data = ${JSON.stringify(messageData(row.type, data))} WHERE id = ${row.id}`,
|
||||
)
|
||||
}
|
||||
|
||||
yield* tx.run(sql`DELETE FROM event WHERE type = 'session.compaction.delta.1'`)
|
||||
const events = yield* tx.all<{ id: string; aggregateID: string; seq: number; type: string; data: string }>(sql`
|
||||
SELECT id, aggregate_id as aggregateID, seq, type, data
|
||||
FROM event
|
||||
WHERE type IN (
|
||||
'session.skill.activated.1',
|
||||
'session.skill.activated.2',
|
||||
'session.compaction.started.1',
|
||||
'session.compaction.started.2',
|
||||
'session.compaction.ended.1',
|
||||
'session.compaction.failed.1',
|
||||
'session.compaction.failed.2',
|
||||
'session.revert.staged.1',
|
||||
'session.revert.staged.2'
|
||||
)
|
||||
ORDER BY aggregate_id, seq
|
||||
`)
|
||||
const compactionReasons = new Map<string, "auto" | "manual">()
|
||||
for (const row of events) {
|
||||
const data = object(decodeJson(row.data))
|
||||
if (row.type.startsWith("session.compaction.ended.")) {
|
||||
compactionReasons.delete(row.aggregateID)
|
||||
continue
|
||||
}
|
||||
const event = eventData(row.type, data, compactionReasons.get(row.aggregateID))
|
||||
if (row.type.startsWith("session.compaction.started."))
|
||||
compactionReasons.set(row.aggregateID, event.data.reason === "auto" ? "auto" : "manual")
|
||||
if (row.type.startsWith("session.compaction.failed.")) compactionReasons.delete(row.aggregateID)
|
||||
yield* tx.run(
|
||||
sql`UPDATE event SET type = ${event.type}, data = ${JSON.stringify(event.data)} WHERE id = ${row.id}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
||||
function messageData(type: string, data: Record<string, unknown>) {
|
||||
if (type === "skill")
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
skill: data.skill ?? data.id ?? data.name,
|
||||
name: data.name,
|
||||
text: data.text,
|
||||
})
|
||||
if (type === "shell") {
|
||||
const shell = object(data.shell)
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
shellID: data.shellID ?? shell.id,
|
||||
command: data.command ?? shell.command,
|
||||
status: data.status ?? shell.status,
|
||||
exit: data.exit ?? shell.exit,
|
||||
output: data.output,
|
||||
})
|
||||
}
|
||||
if (type === "assistant")
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
agent: data.agent,
|
||||
model: data.model,
|
||||
content: Array.isArray(data.content) ? data.content.map(assistantContent) : data.content,
|
||||
snapshot: data.snapshot,
|
||||
finish: data.finish,
|
||||
cost: data.cost,
|
||||
tokens: data.tokens,
|
||||
error: data.error,
|
||||
retry: data.retry,
|
||||
})
|
||||
if (type === "compaction") {
|
||||
if (data.status === "failed")
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
status: data.status,
|
||||
reason: data.reason,
|
||||
error: data.error ?? genericCompactionError,
|
||||
})
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
status: data.status,
|
||||
reason: data.reason,
|
||||
summary: data.summary,
|
||||
recent: data.recent,
|
||||
})
|
||||
}
|
||||
if (type === "synthetic")
|
||||
return defined({ metadata: data.metadata, time: data.time, text: data.text, description: data.description })
|
||||
const { sessionID: _, ...current } = data
|
||||
return current
|
||||
}
|
||||
|
||||
function assistantContent(value: unknown) {
|
||||
const content = object(value)
|
||||
if (content.type === "text") return defined({ type: content.type, text: content.text })
|
||||
if (content.type === "reasoning")
|
||||
return defined({ type: content.type, text: content.text, state: content.state, time: content.time })
|
||||
if (content.type !== "tool") return content
|
||||
return defined({
|
||||
type: content.type,
|
||||
id: content.id,
|
||||
name: content.name,
|
||||
executed: content.executed,
|
||||
providerState: content.providerState,
|
||||
providerResultState: content.providerResultState,
|
||||
state: toolState(content.state),
|
||||
time: content.time,
|
||||
})
|
||||
}
|
||||
|
||||
function toolState(value: unknown) {
|
||||
const state = object(value)
|
||||
if (state.status === "pending" || state.status === "streaming")
|
||||
return defined({ status: "streaming", input: state.input })
|
||||
if (state.status === "running")
|
||||
return defined({ status: state.status, input: state.input, structured: state.structured, content: state.content })
|
||||
if (state.status === "completed")
|
||||
return defined({
|
||||
status: state.status,
|
||||
input: state.input,
|
||||
structured: state.structured,
|
||||
content: state.content,
|
||||
result: state.result,
|
||||
})
|
||||
if (state.status === "error")
|
||||
return defined({
|
||||
status: state.status,
|
||||
input: state.input,
|
||||
structured: state.structured,
|
||||
content: state.content,
|
||||
error: state.error,
|
||||
result: state.result,
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
function eventData(type: string, data: Record<string, unknown>, compactionReason?: "auto" | "manual") {
|
||||
if (type.startsWith("session.skill.activated."))
|
||||
return {
|
||||
type: "session.skill.activated.1",
|
||||
data: defined({ sessionID: data.sessionID, id: data.id ?? data.name, name: data.name, text: data.text }),
|
||||
}
|
||||
if (type.startsWith("session.compaction.started."))
|
||||
return {
|
||||
type: "session.compaction.started.1",
|
||||
data: defined({
|
||||
sessionID: data.sessionID,
|
||||
reason: data.reason,
|
||||
recent: data.recent ?? "",
|
||||
inputID: data.inputID,
|
||||
}),
|
||||
}
|
||||
if (type.startsWith("session.compaction.failed."))
|
||||
return {
|
||||
type: "session.compaction.failed.1",
|
||||
data: defined({
|
||||
sessionID: data.sessionID,
|
||||
reason: data.reason ?? compactionReason ?? "manual",
|
||||
error: data.error ?? genericCompactionError,
|
||||
inputID: data.inputID,
|
||||
}),
|
||||
}
|
||||
const revert = object(data.revert)
|
||||
return {
|
||||
type: "session.revert.staged.1",
|
||||
data: defined({
|
||||
sessionID: data.sessionID,
|
||||
revert: defined({
|
||||
messageID: revert.messageID,
|
||||
partID: revert.partID,
|
||||
snapshot: revert.snapshot,
|
||||
files: Array.isArray(revert.files)
|
||||
? revert.files.map((value) => {
|
||||
const file = object(value)
|
||||
return defined({
|
||||
file: file.file ?? file.path,
|
||||
patch: file.patch,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
status: file.status,
|
||||
})
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const genericCompactionError = {
|
||||
type: "compaction.failed",
|
||||
message: "Compaction failed before recording an error",
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return isObject(value) ? value : {}
|
||||
}
|
||||
|
||||
function defined(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined))
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260709013000_generic_session_input",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
|
||||
yield* tx.run(`
|
||||
DELETE FROM \`event\`
|
||||
WHERE \`type\` IN ('session.prompt.admitted.1', 'session.prompt.promoted.1')
|
||||
AND json_extract(\`data\`, '$.inputID') IN (
|
||||
SELECT \`id\` FROM \`session_input\` WHERE \`type\` = 'prompt' AND \`prompt\` IS NULL
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`__new_session_input\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
\`delivery\` text,
|
||||
\`admitted_seq\` integer NOT NULL,
|
||||
\`promoted_seq\` integer,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
INSERT INTO \`__new_session_input\`(
|
||||
\`id\`, \`session_id\`, \`type\`, \`data\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\`
|
||||
)
|
||||
SELECT
|
||||
\`id\`, \`session_id\`, CASE WHEN \`type\` = 'prompt' THEN 'user' ELSE \`type\` END,
|
||||
CASE WHEN \`type\` = 'prompt' THEN \`prompt\` ELSE '{}' END,
|
||||
\`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\`
|
||||
FROM \`session_input\`
|
||||
WHERE \`type\` != 'prompt' OR \`prompt\` IS NOT NULL;
|
||||
`)
|
||||
yield* tx.run(`DROP TABLE \`session_input\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`)
|
||||
yield* tx.run(`PRAGMA foreign_keys=ON;`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE \`type\` = 'compaction' and \`promoted_seq\` is null;`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(`
|
||||
UPDATE \`event\`
|
||||
SET
|
||||
\`type\` = 'session.input.admitted.1',
|
||||
\`data\` = json_object(
|
||||
'sessionID', json_extract(\`data\`, '$.sessionID'),
|
||||
'inputID', json_extract(\`data\`, '$.inputID'),
|
||||
'input', json_object(
|
||||
'type', 'user',
|
||||
'data', json_extract(\`data\`, '$.prompt'),
|
||||
'delivery', json_extract(\`data\`, '$.delivery')
|
||||
)
|
||||
)
|
||||
WHERE \`type\` = 'session.prompt.admitted.1';
|
||||
`)
|
||||
yield* tx.run(`
|
||||
UPDATE \`event\`
|
||||
SET \`type\` = 'session.input.promoted.1'
|
||||
WHERE \`type\` = 'session.prompt.promoted.1';
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260709025533_drop-todo",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`todo_session_idx\`;`)
|
||||
yield* tx.run(`DROP TABLE \`todo\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260709163752_time_suspended",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD \`time_suspended\` integer;`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_time_suspended_idx\` ON \`session\` (\`time_suspended\`) WHERE "session"."time_suspended" is not null;`,
|
||||
)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260709190621_session_pending_table",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
// Beta reset: session_input becomes the pending-only session_pending
|
||||
// table. Dropping the old table discards consumed ledger rows and any
|
||||
// in-flight pending work along with every historical index variant.
|
||||
yield* tx.run(`DROP TABLE \`session_input\`;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`session_pending\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
\`delivery\` text,
|
||||
\`admitted_seq\` integer NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_pending_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_pending_session_delivery_seq_idx\` ON \`session_pending\` (\`session_id\`,\`delivery\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_pending_session_compaction_idx\` ON \`session_pending\` (\`session_id\`) WHERE "session_pending"."type" = 'compaction';`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_pending_session_admitted_seq_idx\` ON \`session_pending\` (\`session_id\`,\`admitted_seq\`);`,
|
||||
)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260710025429_instruction_sync",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_seq\` integer;`)
|
||||
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`__new_instruction_entry\` (
|
||||
\`session_id\` text NOT NULL,
|
||||
\`key\` text NOT NULL,
|
||||
\`value\` text,
|
||||
\`removed\` integer DEFAULT false NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
|
||||
CONSTRAINT \`fk_instruction_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
INSERT INTO \`__new_instruction_entry\`(
|
||||
\`session_id\`, \`key\`, \`value\`, \`removed\`, \`time_created\`, \`time_updated\`
|
||||
)
|
||||
SELECT \`session_id\`, \`key\`, \`value\`, false, \`time_created\`, \`time_updated\`
|
||||
FROM \`instruction_entry\`;
|
||||
`)
|
||||
yield* tx.run(`DROP TABLE \`instruction_entry\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`__new_instruction_entry\` RENAME TO \`instruction_entry\`;`)
|
||||
yield* tx.run(`PRAGMA foreign_keys=ON;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`instruction_blob\` (
|
||||
\`hash\` text PRIMARY KEY,
|
||||
\`value\` text
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`instruction_state\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`epoch_start\` integer NOT NULL,
|
||||
\`through_seq\` integer NOT NULL,
|
||||
\`initial_values\` text NOT NULL,
|
||||
\`current_values\` text NOT NULL,
|
||||
CONSTRAINT \`fk_instruction_state_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
// Persisted System rows were exclusively pre-beta instruction prose,
|
||||
// including fork copies whose message IDs no longer match the source event.
|
||||
yield* tx.run(`DELETE FROM \`session_message\` WHERE \`type\` = 'system';`)
|
||||
yield* tx.run(`
|
||||
UPDATE \`session\`
|
||||
SET \`fork_seq\` = COALESCE(
|
||||
(
|
||||
SELECT MIN(\`seq\`) - 1
|
||||
FROM \`event\`
|
||||
WHERE \`aggregate_id\` = \`session\`.\`id\` AND \`seq\` > 0
|
||||
),
|
||||
(
|
||||
SELECT \`seq\`
|
||||
FROM \`event_sequence\`
|
||||
WHERE \`aggregate_id\` = \`session\`.\`id\`
|
||||
),
|
||||
0
|
||||
)
|
||||
WHERE \`fork_session_id\` IS NOT NULL;
|
||||
`)
|
||||
yield* tx.run(`
|
||||
UPDATE \`event\`
|
||||
SET
|
||||
\`type\` = 'session.forked.2',
|
||||
\`data\` = json_set(
|
||||
\`data\`,
|
||||
'$.parentSeq',
|
||||
COALESCE(
|
||||
(SELECT \`fork_seq\` FROM \`session\` WHERE \`id\` = \`event\`.\`aggregate_id\`),
|
||||
0
|
||||
)
|
||||
)
|
||||
WHERE \`type\` = 'session.forked.1';
|
||||
`)
|
||||
yield* tx.run(`DELETE FROM \`event\` WHERE \`type\` = 'session.instructions.updated.1';`)
|
||||
yield* tx.run(`DROP TABLE \`instruction_checkpoint\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260716020354_kv",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`kv\` (
|
||||
\`key\` text PRIMARY KEY,
|
||||
\`value\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260722011141_delete_tool_progress_events",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`event\` WHERE \`type\` = 'session.tool.progress.1';`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,123 +0,0 @@
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const isJsonObject = Schema.is(Schema.Record(Schema.String, Schema.Json))
|
||||
|
||||
const object = (value: unknown): Record<string, unknown> => (isObject(value) ? value : {})
|
||||
|
||||
const stringify = (value: unknown) => {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? String(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
const contentOf = (state: Record<string, unknown>) => (Array.isArray(state.content) ? state.content : [])
|
||||
const resultOf = (state: Record<string, unknown>) =>
|
||||
isObject(state.result) && "value" in state.result ? state.result.value : state.result
|
||||
const metadataOf = (state: Record<string, unknown>) => {
|
||||
if (isJsonObject(state.structured) && Object.keys(state.structured).length > 0)
|
||||
return { metadata: state.structured }
|
||||
return isJsonObject(state.metadata) ? { metadata: state.metadata } : {}
|
||||
}
|
||||
const completedContent = (state: Record<string, unknown>) => {
|
||||
const preserved = contentOf(state)
|
||||
if (preserved.length > 0) return preserved
|
||||
return [{ type: "text", text: stringify(Object.keys(object(state.structured)).length ? state.structured : resultOf(state)) }]
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time rewrite of projected tool rows into the canonical result shape:
|
||||
* terminal states store model content plus optional metadata; the generic
|
||||
* `structured` and `result` fields disappear. Provider-hosted result payloads
|
||||
* move into provider-owned result state so hosted continuation survives.
|
||||
* Pre-release durable event versions are intentionally left untouched.
|
||||
*/
|
||||
export default {
|
||||
id: "20260722170000_canonical_tool_results",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
// Keyset-paginated batches keep memory bounded: production databases hold
|
||||
// gigabytes of assistant rows, and materializing them all at once was
|
||||
// measured at a ~5GB RSS spike.
|
||||
let cursor = ""
|
||||
while (true) {
|
||||
const messages = yield* tx.all<{ id: string; data: string }>(
|
||||
sql`SELECT id, data FROM session_message WHERE type = 'assistant' AND id > ${cursor} ORDER BY id LIMIT 1000`,
|
||||
)
|
||||
if (messages.length === 0) break
|
||||
cursor = messages[messages.length - 1].id
|
||||
yield* rewrite(tx, messages)
|
||||
}
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
||||
function rewrite(tx: Parameters<DatabaseMigration.Migration["up"]>[0], messages: { id: string; data: string }[]) {
|
||||
return Effect.gen(function* () {
|
||||
for (const row of messages) {
|
||||
// A row that never decoded is skipped rather than failing the whole
|
||||
// migration on every startup; it was equally unreadable before.
|
||||
const decoded = decodeJson(row.data)
|
||||
if (decoded._tag === "None") {
|
||||
yield* Effect.logWarning("skipping undecodable session_message row").pipe(Effect.annotateLogs({ id: row.id }))
|
||||
continue
|
||||
}
|
||||
const data = object(decoded.value)
|
||||
if (!Array.isArray(data.content)) continue
|
||||
let changed = false
|
||||
const content = data.content.map((part) => {
|
||||
const tool = object(part)
|
||||
if (tool.type !== "tool" || !isObject(tool.state)) return part
|
||||
const state = tool.state
|
||||
if (state.status !== "completed" && state.status !== "error" && state.status !== "running") return part
|
||||
if (!("structured" in state) && !("result" in state)) return part
|
||||
changed = true
|
||||
if (state.status === "running")
|
||||
return {
|
||||
...tool,
|
||||
state: {
|
||||
status: "running",
|
||||
input: object(state.input),
|
||||
metadata: object(state.structured),
|
||||
},
|
||||
}
|
||||
// Hosted payloads are irreducible provider replay state; keep them under
|
||||
// the provider-owned result state instead of a generic result field.
|
||||
const hosted =
|
||||
tool.executed === true && isObject(state.result) && "value" in state.result
|
||||
? { providerResultState: { ...object(tool.providerResultState), result: state.result.value } }
|
||||
: {}
|
||||
const preserved = contentOf(state)
|
||||
if (state.status === "completed")
|
||||
return {
|
||||
...tool,
|
||||
...hosted,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: object(state.input),
|
||||
content: completedContent(state),
|
||||
...metadataOf(state),
|
||||
},
|
||||
}
|
||||
return {
|
||||
...tool,
|
||||
...hosted,
|
||||
state: {
|
||||
status: "error",
|
||||
input: object(state.input),
|
||||
error: state.error,
|
||||
...(preserved.length > 0 ? { content: preserved } : {}),
|
||||
...metadataOf(state),
|
||||
},
|
||||
}
|
||||
})
|
||||
if (!changed) continue
|
||||
yield* tx.run(sql`UPDATE session_message SET data = ${JSON.stringify({ ...data, content })} WHERE id = ${row.id}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260729022634_session_fork_boundary",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_boundary\` text;`)
|
||||
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_message_id\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_seq\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260730195856_optional_session_title",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session\` RENAME COLUMN \`title\` TO \`title_old\``)
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD COLUMN \`title\` text`)
|
||||
yield* tx.run(`UPDATE \`session\` SET \`title\` = \`title_old\``)
|
||||
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`title_old\``)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260804233008_loose_psylocke",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`kv\` (
|
||||
\`key\` text PRIMARY KEY,
|
||||
\`value\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`instruction_blob\` (
|
||||
\`hash\` text PRIMARY KEY,
|
||||
\`value\` text
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`instruction_entry\` (
|
||||
\`session_id\` text NOT NULL,
|
||||
\`key\` text NOT NULL,
|
||||
\`value\` text,
|
||||
\`removed\` integer DEFAULT false NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
|
||||
CONSTRAINT \`fk_instruction_entry_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`instruction_state\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`epoch_start\` integer NOT NULL,
|
||||
\`through_seq\` integer NOT NULL,
|
||||
\`initial_values\` text NOT NULL,
|
||||
\`current_values\` text NOT NULL,
|
||||
CONSTRAINT \`fk_instruction_state_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`session_pending\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
\`delivery\` text,
|
||||
\`admitted_seq\` integer NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_pending_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`session_v2\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`project_id\` text NOT NULL,
|
||||
\`workspace_id\` text,
|
||||
\`parent_id\` text,
|
||||
\`fork_session_id\` text,
|
||||
\`fork_boundary\` text,
|
||||
\`slug\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`path\` text,
|
||||
\`title\` text,
|
||||
\`version\` text NOT NULL,
|
||||
\`share_url\` text,
|
||||
\`summary_additions\` integer,
|
||||
\`summary_deletions\` integer,
|
||||
\`summary_files\` integer,
|
||||
\`summary_diffs\` text,
|
||||
\`metadata\` text,
|
||||
\`cost\` real DEFAULT 0 NOT NULL,
|
||||
\`tokens_input\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_output\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_reasoning\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_cache_read\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_cache_write\` integer DEFAULT 0 NOT NULL,
|
||||
\`revert\` text,
|
||||
\`permission\` text,
|
||||
\`agent\` text,
|
||||
\`model\` text,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`ALTER TABLE \`event\` ADD \`created\` integer DEFAULT 0 NOT NULL;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`__new_session_message\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`seq\` integer NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_session_message_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`DROP TABLE \`session_message\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`__new_session_message\` RENAME TO \`session_message\`;`)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_pending_session_delivery_seq_idx\` ON \`session_pending\` (\`session_id\`,\`delivery\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_pending_session_compaction_idx\` ON \`session_pending\` (\`session_id\`) WHERE "session_pending"."type" = 'compaction';`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_pending_session_admitted_seq_idx\` ON \`session_pending\` (\`session_id\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
|
||||
)
|
||||
yield* tx.run(`DROP TABLE \`data_migration\`;`)
|
||||
yield* tx.run(`DROP TABLE \`session_context_epoch\`;`)
|
||||
yield* tx.run(`DROP TABLE \`session_input\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -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<DatabaseMigration.Migration["up"]>[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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -17,12 +17,6 @@ export default {
|
||||
CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`data_migration\` (
|
||||
\`name\` text PRIMARY KEY,
|
||||
\`time_completed\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`account_state\` (
|
||||
\`id\` integer PRIMARY KEY,
|
||||
@@ -81,7 +75,7 @@ export default {
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`aggregate_id\` text NOT NULL,
|
||||
\`seq\` integer NOT NULL,
|
||||
\`created\` integer NOT NULL,
|
||||
\`created\` integer DEFAULT 0 NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
|
||||
@@ -148,7 +142,7 @@ export default {
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
|
||||
CONSTRAINT \`fk_instruction_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
CONSTRAINT \`fk_instruction_entry_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
@@ -158,28 +152,7 @@ export default {
|
||||
\`through_seq\` integer NOT NULL,
|
||||
\`initial_values\` text NOT NULL,
|
||||
\`current_values\` text NOT NULL,
|
||||
CONSTRAINT \`fk_instruction_state_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`message\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`part\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`message_id\` text NOT NULL,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_part_message_id_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`message\`(\`id\`) ON DELETE CASCADE
|
||||
CONSTRAINT \`fk_instruction_state_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
@@ -191,7 +164,7 @@ export default {
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_session_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
CONSTRAINT \`fk_session_message_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
@@ -203,11 +176,11 @@ export default {
|
||||
\`delivery\` text,
|
||||
\`admitted_seq\` integer NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_pending_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
CONSTRAINT \`fk_session_pending_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`session\` (
|
||||
CREATE TABLE \`session_v2\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`project_id\` text NOT NULL,
|
||||
\`workspace_id\` text,
|
||||
@@ -240,18 +213,7 @@ export default {
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
CONSTRAINT \`fk_session_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`session_share\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`id\` text NOT NULL,
|
||||
\`secret\` text NOT NULL,
|
||||
\`url\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_share_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
|
||||
@@ -259,11 +221,6 @@ export default {
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
|
||||
)
|
||||
@@ -283,11 +240,11 @@ export default {
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_pending_session_admitted_seq_idx\` ON \`session_pending\` (\`session_id\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_time_suspended_idx\` ON \`session\` (\`time_suspended\`) WHERE "session"."time_suspended" is not null;`,
|
||||
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -0,0 +1,903 @@
|
||||
export * as V1Migration from "./v1-migration"
|
||||
|
||||
import { Effect, Option, Schema, Semaphore } from "effect"
|
||||
import { Database } from "./database"
|
||||
import { SessionMessageTable, SessionTable } from "../session/sql"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { KVTable } from "../kv/sql"
|
||||
import { EventSequenceTable, EventTable } from "../event/sql"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
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"
|
||||
|
||||
export type SourceMessage = {
|
||||
readonly id: string
|
||||
readonly session_id: string
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly data: string
|
||||
}
|
||||
|
||||
export type SourcePart = {
|
||||
readonly id: string
|
||||
readonly message_id: string
|
||||
readonly session_id: string
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly data: string
|
||||
}
|
||||
|
||||
export type TransformInput = {
|
||||
readonly session: typeof SessionTable.$inferSelect
|
||||
readonly messages: ReadonlyArray<SourceMessage>
|
||||
readonly parts: ReadonlyArray<SourcePart>
|
||||
}
|
||||
|
||||
export type Warning = {
|
||||
readonly reason: string
|
||||
readonly sessionID: string
|
||||
readonly messageID?: string
|
||||
readonly partID?: string
|
||||
readonly observedType?: string
|
||||
}
|
||||
|
||||
export type TransformResult = {
|
||||
readonly messages: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly session_id: string
|
||||
readonly type: SessionMessage.Type
|
||||
readonly seq: number
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly data: Record<string, unknown>
|
||||
}>
|
||||
readonly session: Pick<
|
||||
typeof SessionTable.$inferInsert,
|
||||
| "agent"
|
||||
| "model"
|
||||
| "cost"
|
||||
| "tokens_input"
|
||||
| "tokens_output"
|
||||
| "tokens_reasoning"
|
||||
| "tokens_cache_read"
|
||||
| "tokens_cache_write"
|
||||
| "revert"
|
||||
| "time_compacting"
|
||||
>
|
||||
readonly watermark: number
|
||||
readonly warnings: ReadonlyArray<Warning>
|
||||
}
|
||||
|
||||
export type Status = {
|
||||
readonly status: "required" | "running" | "completed"
|
||||
readonly completed: number
|
||||
readonly total: number
|
||||
}
|
||||
|
||||
export type Result = {
|
||||
readonly status: "completed"
|
||||
}
|
||||
|
||||
type Options = {
|
||||
readonly nextDatabasePath?: 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 cursorKey = "migration.v1-v2.session.cursor"
|
||||
const completedKey = "migration.v1-v2.completed"
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
|
||||
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
|
||||
let running = false
|
||||
let nextCompleted = 0
|
||||
|
||||
export function transformSession(input: TransformInput): TransformResult {
|
||||
const warnings: Warning[] = []
|
||||
const messages = input.messages
|
||||
.map((row) => {
|
||||
const value = Option.getOrUndefined(decodeJson(row.data))
|
||||
const decoded =
|
||||
value && typeof value === "object"
|
||||
? Option.getOrUndefined(decodeMessage({ ...value, id: row.id, sessionID: row.session_id }))
|
||||
: undefined
|
||||
if (decoded) return { row, value: decoded }
|
||||
warnings.push({ reason: "invalid-message", sessionID: input.session.id, messageID: row.id })
|
||||
return undefined
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== undefined)
|
||||
.sort((a, b) => a.row.time_created - b.row.time_created || a.row.id.localeCompare(b.row.id))
|
||||
const messageIDs = new Set(input.messages.map((row) => row.id))
|
||||
const parts = input.parts
|
||||
.map((row) => {
|
||||
const value = Option.getOrUndefined(decodeJson(row.data))
|
||||
const observedType = value && typeof value === "object" && "type" in value ? String(value.type) : undefined
|
||||
if (!messageIDs.has(row.message_id)) {
|
||||
warnings.push({
|
||||
reason: "orphan-part",
|
||||
sessionID: input.session.id,
|
||||
messageID: row.message_id,
|
||||
partID: row.id,
|
||||
observedType,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
const decoded =
|
||||
value && typeof value === "object"
|
||||
? Option.getOrUndefined(
|
||||
decodePart({ ...value, id: row.id, messageID: row.message_id, sessionID: row.session_id }),
|
||||
)
|
||||
: undefined
|
||||
if (decoded) return { row, value: decoded }
|
||||
warnings.push({
|
||||
reason: "invalid-part",
|
||||
sessionID: input.session.id,
|
||||
messageID: row.message_id,
|
||||
partID: row.id,
|
||||
observedType,
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== undefined)
|
||||
.sort((a, b) => a.row.id.localeCompare(b.row.id))
|
||||
const byMessage = Map.groupBy(parts, (item) => item.row.message_id)
|
||||
const paired = new Set<string>()
|
||||
const used = new Set(messages.map((item) => item.row.id))
|
||||
const projected = messages
|
||||
.flatMap((item) => {
|
||||
if (paired.has(item.row.id)) return []
|
||||
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
|
||||
if (item.value.role === "user") {
|
||||
const compaction = owned.find((part) => part.type === "compaction")
|
||||
if (compaction?.type === "compaction") {
|
||||
const pairedSummary = messages.find(
|
||||
(candidate) =>
|
||||
candidate.value.role === "assistant" &&
|
||||
candidate.value.parentID === item.row.id &&
|
||||
candidate.value.summary,
|
||||
)
|
||||
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
|
||||
paired.add(pairedSummary.row.id)
|
||||
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
|
||||
const summary = pairedSummary
|
||||
const summaryText = (byMessage.get(summary.row.id) ?? [])
|
||||
.map((part) => part.value)
|
||||
.filter((part) => part.type === "text" && part.text.length > 0)
|
||||
.map((part) => (part.type === "text" ? part.text : ""))
|
||||
.join("\n\n")
|
||||
const tailIndex = compaction.tail_start_id
|
||||
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
|
||||
: -1
|
||||
const compactionIndex = messages.findIndex((candidate) => candidate.row.id === item.row.id)
|
||||
const tail = tailIndex < 0 ? [] : messages.slice(tailIndex, compactionIndex)
|
||||
return [
|
||||
row(
|
||||
{ ...item.row, time_updated: Math.max(item.row.time_updated, summary.row.time_updated) },
|
||||
{
|
||||
id: item.row.id,
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: compaction.auto ? "auto" : "manual",
|
||||
summary: summaryText,
|
||||
recent: serializeRecent(tail, byMessage),
|
||||
time: { created: item.row.time_created },
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
const subtasks = owned.filter((part) => part.type === "subtask")
|
||||
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
|
||||
const files = owned.filter((part) => part.type === "file")
|
||||
const agents = owned.filter((part) => part.type === "agent")
|
||||
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
|
||||
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
|
||||
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
|
||||
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
|
||||
const unavailable = files.flatMap((part) =>
|
||||
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
|
||||
)
|
||||
const text = owned
|
||||
.flatMap((part) => {
|
||||
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
|
||||
if (part.type === "file" && !part.url.startsWith("data:")) return [unavailableFile(part)]
|
||||
return []
|
||||
})
|
||||
.join("\n\n")
|
||||
const agentAttachments = agents.map((part) =>
|
||||
part.type === "agent"
|
||||
? {
|
||||
name: part.name,
|
||||
...(part.source
|
||||
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
|
||||
: {}),
|
||||
}
|
||||
: { name: "" },
|
||||
)
|
||||
if (
|
||||
ordinary.length === 0 &&
|
||||
unavailable.length === 0 &&
|
||||
synthetic.length > 0 &&
|
||||
attachments.length === 0 &&
|
||||
agentAttachments.length === 0
|
||||
)
|
||||
return [
|
||||
row(item.row, {
|
||||
id: item.row.id,
|
||||
type: "synthetic",
|
||||
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
|
||||
time: { created: item.row.time_created },
|
||||
}),
|
||||
]
|
||||
const user = row(item.row, {
|
||||
id: item.row.id,
|
||||
type: "user",
|
||||
text,
|
||||
...(attachments.length ? { files: attachments } : {}),
|
||||
...(agentAttachments.length ? { agents: agentAttachments } : {}),
|
||||
time: { created: item.row.time_created },
|
||||
})
|
||||
if (synthetic.length === 0) return [user]
|
||||
return [
|
||||
user,
|
||||
row(item.row, {
|
||||
id: syntheticID(item.row.id, used),
|
||||
type: "synthetic",
|
||||
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
|
||||
time: { created: item.row.time_created },
|
||||
}),
|
||||
]
|
||||
}
|
||||
if (item.value.role !== "assistant") return []
|
||||
const assistant = item.value
|
||||
const parent = messages.find((candidate) => candidate.row.id === assistant.parentID)
|
||||
const parentParts = parent ? (byMessage.get(parent.row.id)?.map((part) => part.value) ?? []) : []
|
||||
if (
|
||||
parentParts.some((part) => part.type === "subtask") &&
|
||||
owned.some((part) => part.type === "tool" && part.tool === "task")
|
||||
)
|
||||
return []
|
||||
const content = owned.flatMap((part): Array<Record<string, unknown>> => {
|
||||
if (part.type === "text")
|
||||
return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }]
|
||||
if (part.type === "reasoning")
|
||||
return [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: part.text,
|
||||
...(part.metadata ? { state: part.metadata } : {}),
|
||||
time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) },
|
||||
},
|
||||
]
|
||||
if (part.type !== "tool") return []
|
||||
return [migrateTool(part, item.row.time_created)]
|
||||
})
|
||||
const start =
|
||||
owned.flatMap((part) => (part.type === "step-start" && part.snapshot ? [part.snapshot] : []))[0] ??
|
||||
owned.flatMap((part) => (part.type === "snapshot" ? [part.snapshot] : []))[0] ??
|
||||
owned.flatMap((part) => (part.type === "patch" ? [part.hash] : []))[0]
|
||||
const end = owned.flatMap((part) => (part.type === "step-finish" && part.snapshot ? [part.snapshot] : [])).at(-1)
|
||||
const snapshotFiles = Array.from(new Set(owned.flatMap((part) => (part.type === "patch" ? part.files : []))))
|
||||
const finish = normalizeFinish(assistant.finish)
|
||||
return [
|
||||
row(item.row, {
|
||||
id: item.row.id,
|
||||
type: "assistant",
|
||||
agent: assistant.agent,
|
||||
model: {
|
||||
providerID: assistant.providerID,
|
||||
id: assistant.modelID,
|
||||
variant: assistant.variant ?? "default",
|
||||
},
|
||||
content,
|
||||
...(start || end || snapshotFiles.length
|
||||
? {
|
||||
snapshot: {
|
||||
...(start ? { start } : {}),
|
||||
...(end ? { end } : {}),
|
||||
...(snapshotFiles.length ? { files: snapshotFiles } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(finish ? { finish } : {}),
|
||||
cost: assistant.cost,
|
||||
tokens: {
|
||||
input: assistant.tokens.input,
|
||||
output: assistant.tokens.output,
|
||||
reasoning: assistant.tokens.reasoning,
|
||||
cache: assistant.tokens.cache,
|
||||
},
|
||||
...(assistant.error ? { error: migrateError(assistant.error) } : {}),
|
||||
time: {
|
||||
created: item.row.time_created,
|
||||
...(assistant.time.completed === undefined ? {} : { completed: item.row.time_updated }),
|
||||
},
|
||||
}),
|
||||
]
|
||||
})
|
||||
.map((item, seq) => ({ ...item, seq }))
|
||||
const assistants = messages
|
||||
.filter((item) => item.value.role === "assistant")
|
||||
.map((item) => item.value)
|
||||
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
|
||||
const latestUser = messages.findLast((item) => {
|
||||
if (item.value.role !== "user") return false
|
||||
const owned = byMessage.get(item.row.id) ?? []
|
||||
if (owned.some((part) => part.value.type === "compaction")) return false
|
||||
return !owned.some((part) => part.value.type === "subtask") || !owned.every((part) => part.value.type === "subtask")
|
||||
})
|
||||
return {
|
||||
messages: projected,
|
||||
session: {
|
||||
agent: input.session.agent ?? (latestUser?.value.role === "user" ? latestUser.value.agent : null),
|
||||
model:
|
||||
input.session.model ??
|
||||
(latestUser?.value.role === "user"
|
||||
? {
|
||||
id: latestUser.value.model.modelID,
|
||||
providerID: latestUser.value.model.providerID,
|
||||
variant: latestUser.value.model.variant ?? "default",
|
||||
}
|
||||
: null),
|
||||
cost: assistants.reduce((total, item) => total + item.cost, 0),
|
||||
tokens_input: assistants.reduce((total, item) => total + item.tokens.input, 0),
|
||||
tokens_output: assistants.reduce((total, item) => total + item.tokens.output, 0),
|
||||
tokens_reasoning: assistants.reduce((total, item) => total + item.tokens.reasoning, 0),
|
||||
tokens_cache_read: assistants.reduce((total, item) => total + item.tokens.cache.read, 0),
|
||||
tokens_cache_write: assistants.reduce((total, item) => total + item.tokens.cache.write, 0),
|
||||
revert: null,
|
||||
time_compacting: null,
|
||||
},
|
||||
watermark: projected.length - 1,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
export function status(options: Options = {}): Effect.Effect<Status, never, Database.Service> {
|
||||
return Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const, completed: 0, total: 0 }
|
||||
const completed =
|
||||
(yield* db
|
||||
.select({ value: KVTable.value })
|
||||
.from(KVTable)
|
||||
.where(eq(KVTable.key, completedKey))
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
const cursor = yield* db
|
||||
.select({ value: KVTable.value })
|
||||
.from(KVTable)
|
||||
.where(eq(KVTable.key, cursorKey))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options))
|
||||
const total = legacyTotal + sourceTotal
|
||||
const cursorValue = typeof cursor?.value === "string" ? cursor.value : undefined
|
||||
const migratedLegacy =
|
||||
cursorValue !== undefined
|
||||
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursorValue}`))
|
||||
?.value ?? 0)
|
||||
: 0
|
||||
const migrated = migratedLegacy + (completed ? sourceTotal : running ? nextCompleted : 0)
|
||||
return {
|
||||
status: completed ? ("completed" as const) : running ? ("running" as const) : ("required" as const),
|
||||
completed: completed ? total : migrated,
|
||||
total,
|
||||
}
|
||||
}).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
export function run(options: Options = {}): Effect.Effect<Result, never, Database.Service> {
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
if (yield* hasKey(db, completedKey)) return { status: "completed" as const }
|
||||
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
|
||||
running = true
|
||||
const migrate = Effect.gen(function* () {
|
||||
yield* importNextDatabase(db, nextPath(options))
|
||||
while (true) {
|
||||
const cursor = yield* db
|
||||
.select({ value: KVTable.value })
|
||||
.from(KVTable)
|
||||
.where(eq(KVTable.key, cursorKey))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const cursorValue = typeof cursor?.value === "string" ? cursor.value : undefined
|
||||
const nextID = yield* db.get<{ id: string }>(
|
||||
cursorValue === undefined
|
||||
? sql`SELECT id FROM session ORDER BY id DESC LIMIT 1`
|
||||
: sql`SELECT id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
|
||||
)
|
||||
if (!nextID) break
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx.run(sql`
|
||||
INSERT OR IGNORE INTO session_v2 (
|
||||
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
||||
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
|
||||
)
|
||||
SELECT
|
||||
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
||||
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
|
||||
FROM session
|
||||
WHERE id = ${nextID.id}
|
||||
`)
|
||||
const next = yield* tx
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
|
||||
.get()
|
||||
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
|
||||
const sourceMessages = yield* tx.all<SourceMessage>(
|
||||
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const sourceParts = yield* tx.all<SourcePart>(
|
||||
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
|
||||
yield* Effect.forEach(transformed.warnings, (warning) =>
|
||||
Effect.logWarning("Skipped V1 migration row", warning),
|
||||
)
|
||||
yield* tx.delete(EventTable).where(eq(EventTable.aggregate_id, next.id)).run()
|
||||
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
|
||||
yield* Effect.forEach(transformed.messages, (message) =>
|
||||
tx.run(sql`
|
||||
INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data)
|
||||
VALUES (${message.id}, ${message.session_id}, ${message.type}, ${message.seq}, ${message.time_created}, ${message.time_updated}, ${JSON.stringify(message.data)})
|
||||
`),
|
||||
)
|
||||
yield* tx
|
||||
.update(SessionTable)
|
||||
.set({ ...transformed.session, time_updated: next.time_updated })
|
||||
.where(eq(SessionTable.id, next.id))
|
||||
.run()
|
||||
yield* tx
|
||||
.insert(EventSequenceTable)
|
||||
.values({ aggregate_id: next.id, seq: transformed.watermark })
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: transformed.watermark, owner_id: null },
|
||||
})
|
||||
.run()
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: cursorKey, value: next.id })
|
||||
.onConflictDoUpdate({ target: KVTable.key, set: { value: next.id, time_updated: Date.now() } })
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: completedKey, value: true })
|
||||
.onConflictDoUpdate({ target: KVTable.key, set: { value: true, time_updated: Date.now() } })
|
||||
.run()
|
||||
yield* tx.delete(KVTable).where(eq(KVTable.key, cursorKey)).run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
return yield* migrate.pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
running = false
|
||||
}),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
|
||||
function 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,
|
||||
): Effect.Effect<void, unknown> {
|
||||
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<NextProject, []>("SELECT * FROM project")
|
||||
.all()
|
||||
.map((project) => [project.id, project]),
|
||||
)
|
||||
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
|
||||
nextCompleted = 0
|
||||
for (const session of sessions) {
|
||||
const project = projects.get(session.project_id)
|
||||
if (!project)
|
||||
return yield* Effect.die(
|
||||
new Error(`Previous V2 session ${session.id} references missing project ${session.project_id}`),
|
||||
)
|
||||
const messages = source
|
||||
.query<NextMessage, [string]>(
|
||||
"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* () {
|
||||
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}, ${session.project_id}, ${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)
|
||||
nextCompleted++
|
||||
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<string>) {
|
||||
const prefix = source.slice(0, 16)
|
||||
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
for (let salt = 0; ; salt++) {
|
||||
const hex = new Bun.CryptoHasher("sha256").update(`v1-synthetic:${source}${salt ? `:${salt}` : ""}`).digest("hex")
|
||||
let value = BigInt(`0x${hex}`)
|
||||
let suffix = ""
|
||||
while (suffix.length < 14) {
|
||||
suffix = alphabet[Number(value % 62n)] + suffix
|
||||
value /= 62n
|
||||
}
|
||||
const id = prefix + suffix
|
||||
if (used.has(id)) continue
|
||||
used.add(id)
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
function serializeRecent(
|
||||
messages: ReadonlyArray<{ row: SourceMessage; value: typeof SessionV1.Info.Type }>,
|
||||
parts: Map<string, Array<{ row: SourcePart; value: typeof SessionV1.Part.Type }>>,
|
||||
) {
|
||||
return messages
|
||||
.flatMap((message) => {
|
||||
const owned = parts.get(message.row.id)?.map((part) => part.value) ?? []
|
||||
if (message.value.role === "user")
|
||||
return [
|
||||
`[User]: ${owned
|
||||
.filter((part) => part.type === "text" && !part.ignored)
|
||||
.map((part) => (part.type === "text" ? part.text : ""))
|
||||
.join("\n\n")}`,
|
||||
]
|
||||
return owned.flatMap((part) =>
|
||||
part.type === "text"
|
||||
? [`[Assistant]: ${part.text}`]
|
||||
: part.type === "reasoning" && part.text
|
||||
? [`[Assistant reasoning]: ${part.text}`]
|
||||
: [],
|
||||
)
|
||||
})
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
function hasKey(db: Database.Interface["db"], key: string) {
|
||||
return db
|
||||
.select({ key: KVTable.key })
|
||||
.from(KVTable)
|
||||
.where(eq(KVTable.key, key))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.map((row) => row !== undefined),
|
||||
Effect.orDie,
|
||||
)
|
||||
}
|
||||
|
||||
function hasLegacySessions(db: Database.Interface["db"]) {
|
||||
return db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`).pipe(
|
||||
Effect.map((row) => row !== undefined),
|
||||
Effect.orDie,
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export const EventTable = sqliteTable(
|
||||
.notNull()
|
||||
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
|
||||
seq: integer().notNull(),
|
||||
created: integer().notNull(),
|
||||
created: integer().notNull().default(0),
|
||||
type: text().notNull(),
|
||||
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
|
||||
},
|
||||
|
||||
@@ -18,7 +18,6 @@ import { SessionMessageTable, SessionTable } from "./session/sql"
|
||||
import { SessionSchema } from "./session/schema"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
|
||||
import { Agent } from "./agent"
|
||||
import { SessionV1 } from "./v1/session"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { App } from "./app"
|
||||
import { Slug } from "./util/slug"
|
||||
@@ -221,14 +220,8 @@ export interface Interface {
|
||||
after?: number
|
||||
follow?: boolean
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
|
||||
readonly switchAgent: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
agent: Agent.ID
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
model: Model.Ref
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly move: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -364,45 +357,45 @@ const layer = Layer.effect(
|
||||
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
|
||||
const project = yield* projects.resolve(location.directory)
|
||||
yield* persistProject(project)
|
||||
const now = Date.now()
|
||||
const info = SessionV1.SessionInfo.make({
|
||||
id: sessionID,
|
||||
slug: Slug.create(),
|
||||
version: app.version,
|
||||
projectID: project.id,
|
||||
parentID: input.parentID,
|
||||
directory: location.directory,
|
||||
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
|
||||
workspaceID: location.workspaceID ? Workspace.ID.make(location.workspaceID) : undefined,
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
id: Model.ID.make(input.model.id),
|
||||
providerID: input.model.providerID,
|
||||
variant: input.model.variant,
|
||||
const projected = yield* bus
|
||||
.publish(
|
||||
SessionEvent.Created,
|
||||
{
|
||||
sessionID,
|
||||
slug: Slug.create(),
|
||||
version: app.version,
|
||||
projectID: project.id,
|
||||
parentID: input.parentID,
|
||||
location,
|
||||
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
id: Model.ID.make(input.model.id),
|
||||
providerID: input.model.providerID,
|
||||
variant: input.model.variant,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{ location },
|
||||
)
|
||||
.pipe(
|
||||
Effect.as({ type: "created" } as const),
|
||||
Effect.catchDefect((defect) => {
|
||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||
return Effect.die(defect)
|
||||
}
|
||||
: undefined,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: now, updated: now },
|
||||
})
|
||||
const projected = yield* bus.publish(SessionV1.Event.Created, { sessionID, info }, { location }).pipe(
|
||||
Effect.as({ type: "created" } as const),
|
||||
Effect.catchDefect((defect) => {
|
||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||
return Effect.die(defect)
|
||||
}
|
||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||
return store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||
return store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (projected.type === "existing") return projected.session
|
||||
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
@@ -558,8 +551,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)))
|
||||
@@ -739,23 +731,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)
|
||||
@@ -836,9 +828,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)
|
||||
@@ -937,12 +927,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,
|
||||
|
||||
@@ -56,6 +56,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
const project = pipe(
|
||||
Match.type<SessionEvent.DurableEvent>(),
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
"session.created": () => Effect.void,
|
||||
"session.usage.recorded": () => Effect.void,
|
||||
"session.agent.selected": (event) => {
|
||||
return adapter.appendMessage(
|
||||
|
||||
@@ -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<SessionEvent.Event, { readonly durable: object }>
|
||||
@@ -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<string, unknown>
|
||||
if (value.type !== "step-finish") return undefined
|
||||
if (!("cost" in value) || !("tokens" in value)) return undefined
|
||||
return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] }
|
||||
}
|
||||
|
||||
function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInsert {
|
||||
return {
|
||||
id: info.id,
|
||||
project_id: info.projectID,
|
||||
workspace_id: info.workspaceID ?? null,
|
||||
parent_id: info.parentID,
|
||||
slug: info.slug,
|
||||
directory: info.directory,
|
||||
path: info.path,
|
||||
title: info.title,
|
||||
agent: info.agent,
|
||||
model: info.model,
|
||||
version: info.version,
|
||||
share_url: info.share?.url,
|
||||
summary_additions: info.summary?.additions,
|
||||
summary_deletions: info.summary?.deletions,
|
||||
summary_files: info.summary?.files,
|
||||
summary_diffs: info.summary?.diffs ? [...info.summary.diffs] : undefined,
|
||||
metadata: info.metadata,
|
||||
cost: info.cost ?? 0,
|
||||
tokens_input: (info.tokens ?? { input: 0 }).input,
|
||||
tokens_output: (info.tokens ?? { output: 0 }).output,
|
||||
tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning,
|
||||
tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read,
|
||||
tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write,
|
||||
revert: info.revert
|
||||
? {
|
||||
messageID: SessionMessage.ID.make(info.revert.messageID),
|
||||
partID: info.revert.partID,
|
||||
snapshot: info.revert.snapshot,
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
: null,
|
||||
permission: info.permission ? [...info.permission] : undefined,
|
||||
time_created: info.time.created,
|
||||
time_updated: info.time.updated,
|
||||
time_compacting: info.time.compacting,
|
||||
time_archived: info.time.archived,
|
||||
}
|
||||
}
|
||||
|
||||
function messageData(
|
||||
info: (typeof SessionV1.Event.MessageUpdated.Type)["data"]["info"],
|
||||
): typeof MessageTable.$inferInsert.data {
|
||||
const { id: _, sessionID: __, ...rest } = info
|
||||
return rest as DeepMutable<typeof rest>
|
||||
}
|
||||
|
||||
function partData(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"]): typeof PartTable.$inferInsert.data {
|
||||
const { id: _, messageID: __, sessionID: ___, ...rest } = part
|
||||
return rest as DeepMutable<typeof rest>
|
||||
}
|
||||
|
||||
function applyUsage(
|
||||
db: DatabaseService,
|
||||
sessionID: (typeof SessionV1.Event.MessageUpdated.Type)["data"]["sessionID"],
|
||||
value: Usage,
|
||||
sign = 1,
|
||||
) {
|
||||
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage, sign = 1) {
|
||||
return db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
@@ -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)
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { PermissionV1 } from "../v1/permission"
|
||||
import { Project } from "../project"
|
||||
import type { SessionSchema } from "./schema"
|
||||
import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
||||
import { Workspace } from "../workspace"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { Instruction } from "@opencode-ai/schema/instruction"
|
||||
@@ -18,11 +17,9 @@ import type { RevertV1 } from "@opencode-ai/schema/session-revert"
|
||||
import type { Schema } from "effect"
|
||||
|
||||
type SessionMessageData = Omit<(typeof SessionMessage.Info)["Encoded"], "type" | "id">
|
||||
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
||||
type V1PartData = Omit<SessionV1.Part, "id" | "sessionID" | "messageID">
|
||||
|
||||
export const SessionTable = sqliteTable(
|
||||
"session",
|
||||
"session_v2",
|
||||
{
|
||||
id: text().$type<SessionSchema.ID>().primaryKey(),
|
||||
project_id: text()
|
||||
@@ -64,47 +61,15 @@ export const SessionTable = sqliteTable(
|
||||
time_suspended: integer(),
|
||||
},
|
||||
(table) => [
|
||||
index("session_project_idx").on(table.project_id),
|
||||
index("session_workspace_idx").on(table.workspace_id),
|
||||
index("session_parent_idx").on(table.parent_id),
|
||||
index("session_time_suspended_idx")
|
||||
index("session_v2_project_idx").on(table.project_id),
|
||||
index("session_v2_workspace_idx").on(table.workspace_id),
|
||||
index("session_v2_parent_idx").on(table.parent_id),
|
||||
index("session_v2_time_suspended_idx")
|
||||
.on(table.time_suspended)
|
||||
.where(sql`${table.time_suspended} is not null`),
|
||||
],
|
||||
)
|
||||
|
||||
export const MessageTable = sqliteTable(
|
||||
"message",
|
||||
{
|
||||
id: text().$type<MessageID>().primaryKey(),
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<V1MessageData>(),
|
||||
},
|
||||
(table) => [index("message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id)],
|
||||
)
|
||||
|
||||
export const PartTable = sqliteTable(
|
||||
"part",
|
||||
{
|
||||
id: text().$type<PartID>().primaryKey(),
|
||||
message_id: text()
|
||||
.$type<MessageID>()
|
||||
.notNull()
|
||||
.references(() => MessageTable.id, { onDelete: "cascade" }),
|
||||
session_id: text().$type<SessionSchema.ID>().notNull(),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<V1PartData>(),
|
||||
},
|
||||
(table) => [
|
||||
index("part_message_id_id_idx").on(table.message_id, table.id),
|
||||
index("part_session_idx").on(table.session_id),
|
||||
],
|
||||
)
|
||||
|
||||
export const SessionMessageTable = sqliteTable(
|
||||
"session_message",
|
||||
{
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { SessionTable } from "../session/sql"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
|
||||
export const SessionShareTable = sqliteTable("session_share", {
|
||||
session_id: text()
|
||||
.primaryKey()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
id: text().notNull(),
|
||||
secret: text().notNull(),
|
||||
url: text().notNull(),
|
||||
...Timestamps,
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
export * as SessionV1 from "./session"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export {
|
||||
AgentPart,
|
||||
AgentPartInput,
|
||||
Assistant,
|
||||
CompactionPart,
|
||||
Event,
|
||||
FilePart,
|
||||
FilePartInput,
|
||||
FilePartSource,
|
||||
FileSource,
|
||||
Format,
|
||||
Info,
|
||||
MessageID,
|
||||
OutputFormatJsonSchema,
|
||||
OutputFormatText,
|
||||
Part,
|
||||
PartID,
|
||||
PatchPart,
|
||||
Range,
|
||||
ReasoningPart,
|
||||
ResourceSource,
|
||||
RetryPart,
|
||||
SessionInfo,
|
||||
SnapshotPart,
|
||||
StepFinishPart,
|
||||
StepStartPart,
|
||||
SubtaskPart,
|
||||
SubtaskPartInput,
|
||||
SymbolSource,
|
||||
TextPart,
|
||||
TextPartInput,
|
||||
ToolPart,
|
||||
ToolState,
|
||||
ToolStateCompleted,
|
||||
ToolStateError,
|
||||
ToolStatePending,
|
||||
ToolStateRunning,
|
||||
User,
|
||||
WithParts,
|
||||
} from "@opencode-ai/schema/session-v1"
|
||||
|
||||
export const OutputLengthError = NamedError.create("MessageOutputLengthError", {})
|
||||
export const AuthError = NamedError.create("ProviderAuthError", { providerID: Schema.String, message: Schema.String })
|
||||
export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String })
|
||||
export const StructuredOutputError = NamedError.create("StructuredOutputError", {
|
||||
message: Schema.String,
|
||||
retries: NonNegativeInt,
|
||||
})
|
||||
export const APIError = NamedError.create("APIError", {
|
||||
message: Schema.String,
|
||||
statusCode: Schema.optional(NonNegativeInt),
|
||||
isRetryable: Schema.Boolean,
|
||||
responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
responseBody: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
export type APIError = Schema.Schema.Type<typeof APIError.Schema>
|
||||
export const ContextOverflowError = NamedError.create("ContextOverflowError", {
|
||||
message: Schema.String,
|
||||
responseBody: Schema.optional(Schema.String),
|
||||
})
|
||||
export const ContentFilterError = NamedError.create("ContentFilterError", { message: Schema.String })
|
||||
@@ -4,7 +4,6 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -89,10 +88,10 @@ const VersionedMessage = Bus.durable({
|
||||
},
|
||||
})
|
||||
|
||||
const DurableMessage = SessionV1.Event.MessageRemoved
|
||||
const DurableMessage = SessionEvent.Renamed
|
||||
const durableData = (sessionID: Session.ID, text: string) => ({
|
||||
sessionID,
|
||||
messageID: SessionV1.MessageID.ascending(`msg_${text}`),
|
||||
title: text,
|
||||
})
|
||||
|
||||
/** Followed log read without markers: the old `durable` stream shape. */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { SessionV1 as Wire } from "@opencode-ai/schema/session-v1"
|
||||
import { SessionV1 } from "../src/v1/session"
|
||||
|
||||
describe("legacy event schema compatibility", () => {
|
||||
test("Core references canonical SessionV1 definitions", () => {
|
||||
expect(SessionV1.Event.Created).toBe(Wire.Event.Created)
|
||||
expect(SessionV1.Event.PartUpdated).toBe(Wire.Event.PartUpdated)
|
||||
})
|
||||
|
||||
test("Core retains NamedError constructor identity", () => {
|
||||
const error = new SessionV1.APIError({ message: "failed", isRetryable: false })
|
||||
expect(error).toBeInstanceOf(SessionV1.APIError)
|
||||
expect(error.toObject()).toEqual({ name: "APIError", data: { message: "failed", isRetryable: false } })
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,6 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
@@ -86,7 +85,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")
|
||||
}),
|
||||
)
|
||||
@@ -451,32 +450,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
|
||||
@@ -484,7 +458,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) }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -502,7 +476,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
|
||||
@@ -516,8 +490,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",
|
||||
@@ -600,7 +575,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)],
|
||||
])
|
||||
@@ -613,7 +588,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)
|
||||
}),
|
||||
@@ -667,7 +642,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" } }])
|
||||
}),
|
||||
)
|
||||
@@ -699,7 +674,9 @@ describe("Session.create", () => {
|
||||
yield* session.switchModel({ sessionID: created.id, model })
|
||||
|
||||
expect(yield* session.get(created.id)).toMatchObject({ model })
|
||||
const bus = Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect))
|
||||
const bus = Array.from(
|
||||
yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect),
|
||||
)
|
||||
expect(bus).toMatchObject([{ type: "session.model.selected" }])
|
||||
expect(bus[0]?.data).toEqual({ sessionID: created.id, model })
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
+271
-3193
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ import { CredentialGroup } from "./groups/credential.js"
|
||||
import { ProjectGroup } from "./groups/project.js"
|
||||
import { ProjectCopyGroup } from "./groups/project-copy.js"
|
||||
import { VcsGroup } from "./groups/vcs.js"
|
||||
import { MigrationGroup } from "./groups/migration.js"
|
||||
|
||||
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
|
||||
@@ -55,7 +56,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
|
||||
type SessionGroups<SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService> =
|
||||
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService>>
|
||||
| HttpApiGroup.AddMiddleware<typeof MessageGroup, SessionLocationId>
|
||||
| typeof MessageGroup
|
||||
|
||||
type FormGroups<
|
||||
LocationId extends HttpApiMiddleware.AnyId,
|
||||
@@ -85,6 +86,7 @@ type ApiGroups<
|
||||
| typeof HealthGroup
|
||||
| typeof ServerGroup
|
||||
| typeof DebugGroup
|
||||
| typeof MigrationGroup
|
||||
| LocationGroups<LocationId>
|
||||
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
|
||||
| SessionGroups<SessionLocationId, SessionLocationService>
|
||||
@@ -150,7 +152,7 @@ const makeApiFromGroup = <
|
||||
.add(AgentGroup.middleware(locationMiddleware))
|
||||
.add(PluginGroup.middleware(locationMiddleware))
|
||||
.add(makeSessionGroup(sessionLocationMiddleware))
|
||||
.add(MessageGroup.middleware(sessionLocationMiddleware))
|
||||
.add(MessageGroup)
|
||||
.add(ModelGroup.middleware(locationMiddleware))
|
||||
.add(GenerateGroup.middleware(locationMiddleware))
|
||||
.add(ProviderGroup.middleware(locationMiddleware))
|
||||
@@ -171,6 +173,7 @@ const makeApiFromGroup = <
|
||||
.add(ProjectCopyGroup.middleware(locationMiddleware))
|
||||
.add(VcsGroup.middleware(locationMiddleware))
|
||||
.add(DebugGroup)
|
||||
.add(MigrationGroup)
|
||||
.add(WebSearchGroup.middleware(locationMiddleware))
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -36,6 +36,7 @@ export const groupNames = {
|
||||
"server.health": "health",
|
||||
"server.server": "server",
|
||||
"server.debug": "debug",
|
||||
"server.migration": "migration",
|
||||
"server.location": "location",
|
||||
"server.agent": "agent",
|
||||
"server.plugin": "plugin",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
export const V1MigrationStatus = Schema.Struct({
|
||||
status: Schema.Literals(["required", "running", "completed"]),
|
||||
completed: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
total: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
})
|
||||
|
||||
export const V1MigrationResult = Schema.Struct({ status: Schema.Literal("completed") })
|
||||
|
||||
export const MigrationGroup = HttpApiGroup.make("server.migration")
|
||||
.add(
|
||||
HttpApiEndpoint.get("migration.v1.status", "/api/experimental/migration/v1", {
|
||||
success: V1MigrationStatus,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.experimental.migration.v1.status",
|
||||
summary: "Get V1 migration status",
|
||||
description: "Return the progress of the V1 to V2 session history migration.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("migration.v1.run", "/api/experimental/migration/v1", {
|
||||
success: V1MigrationResult,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.experimental.migration.v1.run",
|
||||
summary: "Run V1 migration",
|
||||
description: "Run or resume the V1 to V2 session history migration and wait for completion.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "migration" }))
|
||||
@@ -181,7 +181,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
success: Schema.Struct({ data: Session.Info }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.get",
|
||||
@@ -276,7 +275,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, InvalidRequestError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.move",
|
||||
@@ -470,7 +468,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
success: Schema.Struct({ data: Schema.Array(SessionMessage.Info) }),
|
||||
error: [SessionNotFoundError, UnknownError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.context",
|
||||
@@ -485,7 +482,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
success: Schema.Struct({ data: Schema.Array(SessionPending.Info) }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.pending.list",
|
||||
@@ -573,7 +569,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.log",
|
||||
@@ -620,7 +615,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
success: Schema.Struct({ data: SessionMessage.Info }),
|
||||
error: [SessionNotFoundError, MessageNotFoundError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.message",
|
||||
|
||||
@@ -2,11 +2,10 @@ export * as DurableEventManifest from "./durable-event-manifest.js"
|
||||
|
||||
import { Event } from "./event.js"
|
||||
import { SessionEvent } from "./session-event.js"
|
||||
import { SessionV1 } from "./session-v1.js"
|
||||
|
||||
export const SessionDurable = {
|
||||
definitions: Event.durableMap(SessionEvent.DurableDefinitions),
|
||||
schema: SessionEvent.Durable,
|
||||
} as const
|
||||
|
||||
export const Durable = Event.durableMap([...SessionV1.Event.Definitions, ...SessionEvent.DurableDefinitions])
|
||||
export const Durable = Event.durableMap(SessionEvent.DurableDefinitions)
|
||||
|
||||
@@ -29,21 +29,13 @@ import { Skill } from "./skill.js"
|
||||
import { SessionCompactionEvent } from "./session-compaction-event.js"
|
||||
import { SessionEvent } from "./session-event.js"
|
||||
import { SessionStatusEvent } from "./session-status-event.js"
|
||||
import { SessionV1 } from "./session-v1.js"
|
||||
import { TuiEvent } from "./tui-event.js"
|
||||
import { VcsEvent } from "./vcs-event.js"
|
||||
import { WorkspaceEvent } from "./workspace-event.js"
|
||||
import { WorktreeEvent } from "./worktree-event.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
|
||||
const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter(
|
||||
(definition) => definition.durability === "durable",
|
||||
)
|
||||
const sessionV1LiveDefinitions = SessionV1.Event.Definitions.filter(
|
||||
(definition) => definition.durability === "ephemeral",
|
||||
)
|
||||
|
||||
const coreDefinitions = Event.inventory(...sessionV1DurableDefinitions, ...SessionEvent.Definitions)
|
||||
const coreDefinitions = Event.inventory(...SessionEvent.Definitions)
|
||||
|
||||
const foundationDefinitions = Event.inventory(
|
||||
...ModelsDev.Event.Definitions,
|
||||
@@ -79,8 +71,6 @@ export const ServerDefinitions = Event.inventory(
|
||||
...VcsEvent.Definitions,
|
||||
McpEvent.StatusChanged,
|
||||
McpEvent.ResourcesChanged,
|
||||
// Shared transitional event retained until the TUI moves to the current session error surface.
|
||||
SessionV1.Error,
|
||||
)
|
||||
export const Server = Event.latest(ServerDefinitions)
|
||||
export type ServerEvent = Schema.Schema.Type<(typeof ServerDefinitions)[number]>
|
||||
@@ -88,7 +78,6 @@ export const isServer = (event: { readonly type: string }): event is ServerEvent
|
||||
|
||||
export const Definitions = Event.inventory(
|
||||
...foundationDefinitions,
|
||||
...sessionV1LiveDefinitions,
|
||||
...InstallationEvent.Definitions,
|
||||
...featureDefinitions,
|
||||
...LspEvent.Definitions,
|
||||
|
||||
@@ -45,6 +45,24 @@ const options = {
|
||||
version: 1,
|
||||
},
|
||||
} as const
|
||||
export const Created = Event.durable({
|
||||
type: "session.created",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
projectID: Project.ID,
|
||||
location: Location.Ref,
|
||||
subpath: RelativePath.pipe(optional),
|
||||
parentID: SessionID.pipe(optional),
|
||||
slug: Schema.String,
|
||||
title: Schema.String.pipe(optional),
|
||||
agent: Agent.ID.pipe(optional),
|
||||
model: Model.Ref.pipe(optional),
|
||||
version: Schema.String,
|
||||
},
|
||||
})
|
||||
export type Created = typeof Created.Type
|
||||
|
||||
export const AgentSelected = Event.durable({
|
||||
type: "session.agent.selected",
|
||||
...options,
|
||||
@@ -552,6 +570,7 @@ export namespace RevertEvent {
|
||||
}
|
||||
|
||||
export const Definitions = Event.inventory(
|
||||
Created,
|
||||
AgentSelected,
|
||||
ModelSelected,
|
||||
Moved,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"migration.v1.run",
|
||||
Effect.fn(function* () {
|
||||
return yield* V1Migration.run()
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -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({
|
||||
|
||||
@@ -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* <E, R>(
|
||||
options: ServerOptions,
|
||||
lifecycle?: Lifecycle<E, R>,
|
||||
@@ -52,7 +60,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
dispatch(password, status, application, shutdown, options.app?.version ?? "unknown").pipe(
|
||||
HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
|
||||
),
|
||||
HttpMiddleware.logger,
|
||||
errorResponseLogger,
|
||||
)
|
||||
.pipe(withoutParentSpan)
|
||||
if (lifecycle)
|
||||
|
||||
@@ -132,7 +132,9 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
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(
|
||||
|
||||
+35
-24
@@ -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"
|
||||
@@ -95,6 +97,7 @@ import { destroyRenderer } from "./util/renderer"
|
||||
import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
import { StorageProvider } from "./context/storage"
|
||||
import { Migration } from "./migration"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
@@ -187,21 +190,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 +203,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,6 +458,32 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const promptRef = usePromptRef()
|
||||
const plugins = usePlugin()
|
||||
const clipboard = useClipboard()
|
||||
const [migration, setMigration] = createStore({ active: false, completed: 0, total: 0 })
|
||||
const migrationAbort = new AbortController()
|
||||
|
||||
onMount(async () => {
|
||||
await Bun.sleep(1_000)
|
||||
void Migration.run(
|
||||
client.api,
|
||||
(status) =>
|
||||
setMigration({
|
||||
active: status.status !== "completed",
|
||||
completed: status.completed,
|
||||
total: status.total,
|
||||
}),
|
||||
migrationAbort.signal,
|
||||
).catch((error) => {
|
||||
if (migrationAbort.signal.aborted) return
|
||||
setMigration("active", false)
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Data migration failed",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
duration: 10_000,
|
||||
})
|
||||
})
|
||||
})
|
||||
onCleanup(() => migrationAbort.abort())
|
||||
|
||||
// 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
|
||||
@@ -1170,15 +1182,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 +1274,9 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
<Show when={showReconnecting()}>
|
||||
<Reconnecting />
|
||||
</Show>
|
||||
<Show when={migration.active}>
|
||||
<MigrationOverlay completed={migration.completed} total={migration.total} />
|
||||
</Show>
|
||||
<Toast />
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useTheme } from "../context/theme"
|
||||
import { SplitBorder } from "../ui/border"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function MigrationOverlay(props: { completed: number; total: number }) {
|
||||
const theme = useTheme("overlay")
|
||||
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
zIndex={10_000}
|
||||
top={1}
|
||||
right={2}
|
||||
flexDirection="row"
|
||||
backgroundColor={theme.background.default}
|
||||
border={["left"]}
|
||||
borderColor={theme.text.feedback.info.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<Spinner color={theme.text.feedback.info.default}>
|
||||
Migrating sessions {props.completed}/{props.total}
|
||||
</Spinner>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -207,11 +207,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)
|
||||
@@ -223,8 +218,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 }
|
||||
|
||||
@@ -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<OpenCodeEvent, { type: "session.error" }>["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())
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Migration } from "./migration"
|
||||
|
||||
test("skips a completed migration", async () => {
|
||||
const updates: Migration.Status[] = []
|
||||
const client = {
|
||||
migration: {
|
||||
v1: {
|
||||
status: async () => ({ status: "completed" as const, completed: 2, total: 2 }),
|
||||
run: async () => ({ status: "completed" as const }),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(await Migration.run(client, (status) => updates.push(status))).toBe(false)
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
|
||||
test("polls committed session progress while migration runs", async () => {
|
||||
const updates: Migration.Status[] = []
|
||||
let completed = 0
|
||||
let finish: (() => void) | undefined
|
||||
const client = {
|
||||
migration: {
|
||||
v1: {
|
||||
status: async () => ({
|
||||
status: completed === 2 ? ("completed" as const) : ("running" as const),
|
||||
completed,
|
||||
total: 2,
|
||||
}),
|
||||
run: () =>
|
||||
new Promise<{ status: "completed" }>((resolve) => {
|
||||
finish = () => resolve({ status: "completed" })
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const running = Migration.run(client, (status) => updates.push(status))
|
||||
await Bun.sleep(1_050)
|
||||
completed = 2
|
||||
finish?.()
|
||||
expect(await running).toBe(true)
|
||||
expect(updates).toContainEqual({ status: "running", completed: 0, total: 2 })
|
||||
expect(updates).toContainEqual({ status: "completed", completed: 2, total: 2 })
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { OpenCodeClient } from "@opencode-ai/client"
|
||||
|
||||
type Client = Pick<OpenCodeClient, "migration">
|
||||
export type Status = Awaited<ReturnType<Client["migration"]["v1"]["status"]>>
|
||||
|
||||
export async function run(client: Client, update: (status: Status) => void, signal?: AbortSignal) {
|
||||
const initial = await client.migration.v1.status({ signal })
|
||||
if (initial.status === "completed") return false
|
||||
update(initial)
|
||||
|
||||
let polling = false
|
||||
const progress = setInterval(() => {
|
||||
if (polling) return
|
||||
polling = true
|
||||
void client.migration.v1
|
||||
.status({ signal })
|
||||
.then(update)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
polling = false
|
||||
})
|
||||
}, 1_000)
|
||||
|
||||
try {
|
||||
await client.migration.v1.run({ signal })
|
||||
update(await client.migration.v1.status({ signal }))
|
||||
return true
|
||||
} finally {
|
||||
clearInterval(progress)
|
||||
}
|
||||
}
|
||||
|
||||
export * as Migration from "./migration"
|
||||
@@ -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<string>()
|
||||
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" })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
+271
-3193
File diff suppressed because it is too large
Load Diff
+271
-3193
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user