Compare commits

...

65 Commits

Author SHA1 Message Date
Kit Langton 58b2cad830 fix(tui): show completed write output 2026-08-08 22:17:31 -04:00
Kit Langton 756f42e45b fix(tui): align running shell output (#41101) 2026-08-07 12:02:12 -04:00
Kit Langton ac13ae1673 fix(tui): open authorization links (#40905) 2026-08-06 15:21:16 -04:00
Dax Raad 373fb350d8 fix(client): avoid duplicate service contenders 2026-08-06 00:37:07 -04:00
Dax Raad 3fdfc58082 refactor(core): remove migration vacuum 2026-08-05 21:56:41 -04:00
Dax Raad 90b822119e fix(core): expose migration vacuum progress 2026-08-05 21:56:41 -04:00
Dax Raad 35ac83fd29 refactor(core): auto-start v1 migration 2026-08-05 21:56:41 -04:00
Dax Raad 675e8012b0 chore(core): remove migration test delay 2026-08-05 21:56:41 -04:00
Dax Raad b09f691cd5 feat(core): run v1 migration in background 2026-08-05 21:56:41 -04:00
Dax Raad 3dfdd9dce9 fix(core): reassign orphaned sessions 2026-08-05 21:56:41 -04:00
Dax Raad 22cd987123 fix(core): skip orphaned v1 sessions 2026-08-05 21:56:41 -04:00
Dax Raad 09417d5d51 fix(core): skip orphaned beta sessions 2026-08-05 21:56:41 -04:00
Dax Raad 01dd79f984 fix(core): accept migrated Windows paths 2026-08-05 21:56:41 -04:00
Dax Raad 9e23927648 fix: resolve v2 migration rebase 2026-08-05 21:56:41 -04:00
Dax Raad e2af71df7d test(core): update project fixture 2026-08-05 21:56:41 -04:00
Dax Raad 1024884dfd feat(core): import legacy credentials 2026-08-05 21:56:41 -04:00
Dax Raad 28e051ef16 feat(core): import previous v2 sessions 2026-08-05 21:56:41 -04:00
Dax Raad e815beb888 migration 2026-08-05 21:56:41 -04:00
Kit Langton 03e9789064 test(core): align event persistence expectations (#40783) 2026-08-06 01:50:45 +00:00
Kit Langton 0c558a6856 refactor(console): remove unused mail assets (#40758) 2026-08-06 00:29:55 +00:00
Kit Langton f297bd3b8b refactor(desktop): remove disconnected CLI installer (#40751) 2026-08-06 00:29:37 +00:00
Kit Langton 1bfc23f503 refactor(console): remove unused landing assets (#40757) 2026-08-06 00:28:37 +00:00
Kit Langton 3839aafa25 refactor: remove orphaned sqlite package (#40766) 2026-08-06 00:00:40 +00:00
Dax Raad bd2f37bc90 fix(core): clarify current session label 2026-08-05 19:33:59 -04:00
Dax Raad 120e4e7388 fix(core): make event persistence opt-in 2026-08-05 19:33:59 -04:00
Kit Langton 6f91bc7415 fix(tui): load sidebar project names sooner (#40763) 2026-08-05 19:31:42 -04:00
Kit Langton c46f6ae112 refactor(app): remove unused help placeholder (#40756) 2026-08-05 18:53:25 -04:00
Kit Langton 285444aab4 refactor(web): remove superseded ornate logos (#40750) 2026-08-05 22:35:41 +00:00
Kit Langton 56c33e84a3 fix(tui): keep model search order stable (#40753) 2026-08-05 18:19:26 -04:00
Kit Langton 73581b3c3b refactor(web): remove unused icons (#40744) 2026-08-05 21:51:54 +00:00
Kit Langton 6e4d01f846 refactor(session-ui): remove unused panel title (#40741) 2026-08-05 21:50:46 +00:00
Kit Langton 8646587c95 refactor(web): remove unused share anchor (#40742) 2026-08-05 17:46:40 -04:00
Kit Langton fb47f06228 refactor(desktop): remove superseded local sidecar (#40743) 2026-08-05 17:46:19 -04:00
Kit Langton 3acaa5a359 refactor(core): remove unused shell posix metadata (#40734) 2026-08-05 21:31:35 +00:00
Kit Langton 0726a25142 refactor(console): remove abandoned desktop promo (#40737) 2026-08-05 21:30:53 +00:00
Kit Langton 0df96ddeb0 refactor(codemode): remove unused spread helper (#40729) 2026-08-05 21:26:33 +00:00
Kit Langton 887673310b refactor(core): remove unused session exports (#40730) 2026-08-05 21:26:16 +00:00
Kit Langton 693a1bff81 refactor(core): remove unused copy strategy registry (#40735) 2026-08-05 21:25:18 +00:00
Kit Langton b40ed3aa85 refactor(console): remove unused mail helpers (#40740) 2026-08-05 21:24:21 +00:00
Kit Langton 732bb9c3cb refactor(core): remove discarded MCP connection details (#40726) 2026-08-05 17:20:13 -04:00
Kit Langton 2fd1660b4d refactor(core): remove test-only ignore helper (#40733) 2026-08-05 21:15:22 +00:00
Kit Langton 681526d348 refactor(core): remove duplicate directory query (#40731) 2026-08-05 21:10:01 +00:00
Kit Langton e78869c849 refactor(core): remove unused agent default method (#40725) 2026-08-05 20:46:36 +00:00
Kit Langton 934935963d refactor(core): remove unused filesystem search methods (#40703) 2026-08-05 19:42:30 +00:00
Kit Langton f3912a2a8a refactor(app): remove unused UI helpers (#40686) 2026-08-05 15:15:15 -04:00
Kit Langton 45d58717a4 fix(core): bound protected home searches (#40518) 2026-08-05 15:15:02 -04:00
Kit Langton 74e3155ef0 refactor(session-ui): remove unused exported components (#40685) 2026-08-05 15:13:39 -04:00
Kit Langton 0af6c82563 refactor(core): remove orphaned message projector paths (#40705) 2026-08-05 15:13:15 -04:00
Kit Langton 686127f809 refactor(core): remove unreachable repository validation (#40700) 2026-08-05 14:30:29 -04:00
Kit Langton 5256655c4d refactor(core): remove obsolete project commit bridge (#40701) 2026-08-05 18:16:55 +00:00
Kit Langton d10b652637 refactor(core): remove unused formatter methods (#40684) 2026-08-05 13:45:23 -04:00
Kit Langton b03ca0d4e2 refactor(core): remove unused bus sequences (#40681) 2026-08-05 13:41:38 -04:00
Kit Langton 25aaea3d31 refactor(core): remove unused shell tree killer (#40680) 2026-08-05 13:38:59 -04:00
Kit Langton cae7a139bc refactor(server): remove obsolete auth header helpers (#40682) 2026-08-05 13:37:29 -04:00
Kit Langton 5ea62ab05f refactor(core): remove orphaned git change operations (#40683) 2026-08-05 13:32:03 -04:00
Kit Langton faadc05c88 refactor(core): remove unused snapshot operations (#40687) 2026-08-05 13:01:48 -04:00
Kit Langton 3e253c589e refactor(core): persist instruction updates as messages (#40679) 2026-08-05 12:55:29 -04:00
Kit Langton 0a0fc09533 refactor(core): remove unused file mutation methods (#40667) 2026-08-05 12:33:20 -04:00
Kit Langton 5aa0413fea refactor(core): remove dead fork pending copy (#40675) 2026-08-05 12:30:42 -04:00
Kit Langton 6f4c199629 fix(tui): wait for diff request in tests (#40670) 2026-08-05 16:02:22 +00:00
Kit Langton ed8e1f4654 fix(core): reconcile promoted prompt retries from messages (#40664) 2026-08-05 11:43:03 -04:00
Kit Langton 3b0195e045 fix(core): avoid eager directory snapshots (#40552) 2026-08-05 14:52:07 +00:00
Shoubhit Dash 143a776373 fix(acp): surface subagent activity (#40438) 2026-08-05 18:57:15 +05:30
Brendan Allan 76b318e990 refactor(app): gate unsupported v2 capabilities (#40381) 2026-08-05 15:15:26 +08:00
opencode-agent[bot] c0ab35c3c2 fix(app): show active search details (#40597)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-05 17:09:42 +10:00
234 changed files with 5923 additions and 20873 deletions
-14
View File
@@ -489,18 +489,6 @@
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
}, },
}, },
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.8",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.18.8", "version": "1.18.8",
@@ -2067,8 +2055,6 @@
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"], "@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
"@opencode-ai/effect-sqlite-node": ["@opencode-ai/effect-sqlite-node@workspace:packages/effect-sqlite-node"],
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"], "@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
+206 -26
View File
@@ -5,8 +5,27 @@
- Use the `dev` branch database schema and migration registry as the V1 baseline. - Use the `dev` branch database schema and migration registry as the V1 baseline.
- Remove migrations that exist only on the V2 branch. - Remove migrations that exist only on the V2 branch.
- Generate one canonical migration from the `dev` schema to the final V2 schema. - 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. - Keep the canonical migration focused on schema changes and dropping obsolete tables.
- Test the migration against a populated database at the exact `dev` schema. - 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 ## 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 Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
workspace relationships. workspace relationships.
Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the Preserve existing non-null `session.agent` and `session.model` selections. Fill missing values from the latest ordinary
generated migration must not drop the table. 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` Clear `session.time_compacting`, leave the new `time_suspended` column as `NULL`, and preserve session creation, update,
- `event_sequence` and archive timestamps. Preserve project `time_initialized`; it is unrelated durable state.
- `session_message`
These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the Keep the legacy `todo` table and its data physically unchanged, but do not include it in the final V2 Drizzle schema.
column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message` After generation, remove the generated `DROP TABLE todo` statement from the canonical migration so the table remains as
and `part` rows rather than retaining its pre-launch V2 contents. 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 ## 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 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`. 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 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. 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 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`. 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 V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
payload. 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 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 `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. 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 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 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 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. 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 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. 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 retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
assistant row. assistant row.
After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that Do not project incomplete or failed V1 compactions into `session_message`. Omit both the internal compaction user marker
session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting and its paired summary assistant when no successful summary was completed. Assign final sequence numbers after filtering
before migrated history. The `event` table remains empty. 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 ## Drop
@@ -74,6 +233,7 @@ Drop these pre-launch V2 tables without preserving or transforming their rows:
- `session_input` - `session_input`
- `session_context_epoch` - `session_context_epoch`
- `data_migration`
Do not transfer `session_input` rows into `session_pending`. Do not transfer `session_input` rows into `session_pending`.
@@ -103,16 +263,36 @@ schema.
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit 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. 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, Before transforming V1 rows, look for `opencode-next.db` in the data directory. This file was used by pre-launch V2
credentials, permissions, shares, and workspaces. After migration, it should verify: 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. The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor:
- Todo rows remain available in the unchanged `todo` table. the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed.
- `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. Store V1 backfill state in `kv`; do not retain a dedicated `data_migration` table. Store the last successfully migrated
- Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence. session ID under `migration.v1-v2.session.cursor` and write `migration.v1-v2.completed` with value `true` after every
- Dropped tables no longer exist. session finishes. Delete the cursor key on completion and return immediately on later calls when the completion key
- New tables exist and are empty. exists.
- The final schema has no ungenerated changes.
Absence of the completion key means migration is required, including on a fresh database. Running the endpoint against a
database with no sessions completes immediately and writes the completion key; fresh database initialization does not
seed migration state specially.
Process sessions in stable ID order. Rebuild one session in one transaction, including its `session_message` rows,
session-level backfills, `event_sequence` watermark, and cursor update. If interrupted during a session, that transaction
rolls back and the next endpoint call retries the same session. If it committed, the next call continues after the stored
cursor. Mark the migration complete after the final session and return immediately on later calls.
Ensure the global project exists using the current platform's filesystem root as its worktree. Process every `session`
row, including archived, root, child, and empty sessions, as well as sessions whose messages are all skipped or internal.
Reassign beta and V1 Sessions whose referenced project row is missing to the global project and log a warning. Each
successfully committed session advances the cursor.
## Testing
Detailed migration test design is deferred until after the canonical migration is implemented.
@@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression"
const sessionID = "ses_context_resize_regression" const sessionID = "ses_context_resize_regression"
const title = "Context resize regression" const title = "Context resize regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"] const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
const followingTextID = `${id("msg_assistant", 10)}:text:0` const followingTextID = "prt_0104_text"
type Message = { type Message = {
info: Record<string, unknown> & { id: string; role: "user" | "assistant" } info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
@@ -263,7 +263,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
), ),
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status), contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
{ {
id: "prt_0104_text", id: followingTextID,
sessionID, sessionID,
messageID: assistantID, messageID: assistantID,
type: "text", type: "text",
@@ -295,7 +295,7 @@ function contextTool(
sessionID, sessionID,
messageID, messageID,
type: "tool", type: "tool",
callID: partID, callID: `call_${partID}`,
tool, tool,
state: { state: {
status, status,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

@@ -136,7 +136,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) { if (result.key) {
await serverSDK().client.auth.set({ await serverSDK().legacy.auth.set({
providerID: result.providerID, providerID: result.providerID,
auth: { auth: {
type: "api", type: "api",
@@ -71,11 +71,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
() => (missingBase() ? true : undefined), () => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => { async (): Promise<Path | undefined> => {
if ((await sdk.protocol) === "v1") if ((await sdk.protocol) === "v1")
return sdk.client.path return sdk.legacy.path.get().catch(() => undefined)
.get() return sdk.api.location
.then((result) => result.data)
.catch(() => undefined)
return sdk.currentApi.location
.get() .get()
.then((location) => ({ .then((location) => ({
state: "", state: "",
@@ -62,11 +62,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
() => (missingBase() ? true : undefined), () => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => { async (): Promise<Path | undefined> => {
if ((await sdk.protocol) === "v1") if ((await sdk.protocol) === "v1")
return sdk.client.path return sdk.legacy.path.get().catch(() => undefined)
.get() return sdk.api.location
.then((result) => result.data)
.catch(() => undefined)
return sdk.currentApi.location
.get() .get()
.then((location) => ({ .then((location) => ({
state: "", state: "",
+1 -7
View File
@@ -73,7 +73,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
if (props.project.id && props.project.id !== "global") { if (props.project.id && props.project.id !== "global") {
if ((await serverCtx().sdk.protocol) !== "v1") return if ((await serverCtx().sdk.protocol) !== "v1") return
const project = await serverCtx() const project = await serverCtx()
.sdk.client.project.update({ .sdk.legacy.project.update({
projectID: props.project.id, projectID: props.project.id,
directory: props.project.worktree, directory: props.project.worktree,
name, name,
@@ -82,12 +82,6 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
}) })
.then((result) => result.data) .then((result) => result.data)
if (!project) return if (!project) return
// const project = await serverCtx().sdk.api.project.update({
// projectID: props.project.id,
// name,
// icon: { color: store.color || "", override: store.iconOverride || "" },
// commands: { start },
// })
serverCtx().sync.set("project", (items) => serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)), items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
) )
@@ -13,7 +13,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform" import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk" import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action" import { useUpdaterAction } from "./updater-action"
import { import {
monoDefault, monoDefault,
@@ -125,16 +125,11 @@ export const SettingsGeneral: Component = () => {
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const [shells] = createResource( const [shells] = createResource(
async () => { () => (protocol() === "v1" ? serverSdk() : undefined),
const sdk = serverSdk() (sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] }, { initialValue: [] as ShellOption[] },
) )
@@ -325,10 +320,11 @@ export const SettingsGeneral: Component = () => {
</div> </div>
</SettingsRow> </SettingsRow>
<SettingsRow <Show when={protocol() === "v1"}>
title={language.t("settings.general.row.shell.title")} <SettingsRow
description={language.t("settings.general.row.shell.description")} title={language.t("settings.general.row.shell.title")}
> description={language.t("settings.general.row.shell.description")}
>
<Select <Select
data-action="settings-shell" data-action="settings-shell"
options={shellOptions()} options={shellOptions()}
@@ -345,7 +341,8 @@ export const SettingsGeneral: Component = () => {
triggerVariant="settings" triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }} triggerStyle={{ "min-width": "180px" }}
/> />
</SettingsRow> </SettingsRow>
</Show>
<SettingsRow <SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")} title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -122,9 +122,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
const disconnect = async (providerID: string, name: string) => { const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) { if (isConfigCustom(providerID)) {
await serverSDK() await serverSDK().legacy.auth.remove({ providerID }).catch(() => undefined)
.client.auth.remove({ providerID })
.catch(() => undefined)
await disableProvider(providerID, name) await disableProvider(providerID, name)
return return
} }
@@ -10,7 +10,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk" import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action" import { useUpdaterAction } from "../updater-action"
import { import {
monoDefault, monoDefault,
@@ -92,6 +92,7 @@ export const SettingsGeneralV2: Component<{
const settings = useSettings() const settings = useSettings()
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const mobile = createMediaQuery("(max-width: 767px)") const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction() const updater = useUpdaterAction()
@@ -122,14 +123,8 @@ export const SettingsGeneralV2: Component<{
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource( const [shells] = createResource(
async () => { () => (protocol() === "v1" ? serverSdk() : undefined),
const sdk = serverSdk() (sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] }, { initialValue: [] as ShellOption[] },
) )
@@ -284,10 +279,11 @@ export const SettingsGeneralV2: Component<{
</div> </div>
</SettingsRowV2> </SettingsRowV2>
<SettingsRowV2 <Show when={protocol() === "v1"}>
title={language.t("settings.general.row.shell.title")} <SettingsRowV2
description={language.t("settings.general.row.shell.description")} title={language.t("settings.general.row.shell.title")}
> description={language.t("settings.general.row.shell.description")}
>
<SelectV2 <SelectV2
appearance="inline" appearance="inline"
data-action="settings-shell" data-action="settings-shell"
@@ -303,7 +299,8 @@ export const SettingsGeneralV2: Component<{
serverSync().updateConfig({ shell: option.value }) serverSync().updateConfig({ shell: option.value })
}} }}
/> />
</SettingsRowV2> </SettingsRowV2>
</Show>
<SettingsRowV2 <SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")} title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -119,9 +119,7 @@ export const SettingsProvidersV2: Component<{
const disconnect = async (providerID: string, name: string) => { const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) { if (isConfigCustom(providerID)) {
await serverSdk() await serverSdk().legacy.auth.remove({ providerID }).catch(() => undefined)
.client.auth.remove({ providerID })
.catch(() => undefined)
await disableProvider(providerID, name) await disableProvider(providerID, name)
return return
} }
@@ -318,10 +318,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""} {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")} {language.t("status.popover.tab.mcp")}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular"> <Show when={protocol() === "v1"}>
{lspCount() > 0 ? `${lspCount()} ` : ""} <Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{language.t("status.popover.tab.lsp")} {lspCount() > 0 ? `${lspCount()} ` : ""}
</Tabs.Trigger> {language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
</Show>
<Show when={protocol() === "v1"}> <Show when={protocol() === "v1"}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""} {pluginCount() > 0 ? `${pluginCount()} ` : ""}
@@ -459,7 +461,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div> </div>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="lsp"> <Show when={protocol() === "v1"}>
<Tabs.Content value="lsp">
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show <Show
@@ -485,7 +488,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</Show> </Show>
</div> </div>
</div> </div>
</Tabs.Content> </Tabs.Content>
</Show>
<Show when={protocol() === "v1"}> <Show when={protocol() === "v1"}>
<Tabs.Content value="plugins"> <Tabs.Content value="plugins">
-5
View File
@@ -248,11 +248,6 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
return IS_MAC ? parts.join("") : parts.join("+") return IS_MAC ? parts.join("") : parts.join("+")
} }
// KeybindV2 takes an array instead of a string
export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
return formatKeybindParts(config, t)
}
function isEditableTarget(target: EventTarget | null) { function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true if (target.isContentEditable) return true
+1 -2
View File
@@ -134,8 +134,7 @@ export const createDirSyncContext = (
}, },
more: createMemo(() => current()[0].session.length >= current()[0].limit), more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => { archive: async (sessionID: string) => {
if ((await serverSDK.protocol) !== "v1") return await serverSDK.legacy.session.archive(sessionID, directory)
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
current()[1]( current()[1](
"session", "session",
produce((draft) => { produce((draft) => {
-7
View File
@@ -286,13 +286,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
children: tree.children, children: tree.children,
expand: tree.expandDir, expand: tree.expandDir,
collapse: tree.collapseDir, collapse: tree.collapseDir,
toggle(input: string) {
if (tree.dirState(input)?.expanded) {
tree.collapseDir(input)
return
}
tree.expandDir(input)
},
}, },
get, get,
load, load,
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query" import { QueryClient } from "@tanstack/solid-query"
import type { Config, Project } from "@/types" import type { Config, Project } from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { LegacyCapabilities } from "@/utils/server-compat"
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise" import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { import {
@@ -111,36 +111,7 @@ describe("bootstrapDirectory", () => {
project: [{ id: "project", worktree: "/project" } as Project], project: [{ id: "project", worktree: "/project" } as Project],
provider, provider,
}, },
sdk: { legacy: { config: { directory: async () => ({}) } } as unknown as LegacyCapabilities,
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: {
list: async () => {
mcpReads.push("command")
return { data: [] }
},
},
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
mcp: {
status: async () => {
mcpReads.push("status")
return { data: {} }
},
},
experimental: {
resource: {
list: async () => {
mcpReads.push("resource")
return { data: {} }
},
},
},
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
api: currentApi, api: currentApi,
store, store,
setStore, setStore,
@@ -163,16 +134,15 @@ describe("bootstrapDirectory", () => {
describe("query keys", () => { describe("query keys", () => {
test("partitions identical directories by server scope", () => { test("partitions identical directories by server scope", () => {
const location = {} as Parameters<typeof loadPathQuery>[2] const location = {} as Parameters<typeof loadPathQuery>[2]
const client = {} as Parameters<typeof loadPathQuery>[3]
const api = {} as CatalogApi const api = {} as CatalogApi
const remote = "https://debian.example" as typeof ServerScope.local const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", location, client).queryKey]).toEqual([ expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual([
"local", "local",
"/repo", "/repo",
"path", "path",
]) ])
expect([...loadPathQuery(remote, "/repo", location, client).queryKey]).toEqual([ expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual([
"https://debian.example", "https://debian.example",
"/repo", "/repo",
"path", "path",
@@ -8,7 +8,7 @@ import type {
ReferenceInfo, ReferenceInfo,
Session, Session,
} from "@/types" } from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { LegacyCapabilities } from "@/utils/server-compat"
import type { import type {
AgentListInput, AgentListInput,
AgentListOutput, AgentListOutput,
@@ -107,10 +107,11 @@ function showErrors(input: {
}) })
} }
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) => export const loadGlobalConfigQuery = (scope: ServerScope, legacy: LegacyCapabilities, enabled = true) =>
queryOptions({ queryOptions({
queryKey: [scope, "config"], queryKey: [scope, "config"],
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)), queryFn: () => retry(() => legacy.config.global()),
enabled,
}) })
type ProjectApi = { type ProjectApi = {
@@ -141,7 +142,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
}) })
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient legacy: LegacyCapabilities
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi } serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
protocol?: Promise<ServerProtocol> protocol?: Promise<ServerProtocol>
scope: ServerScope scope: ServerScope
@@ -151,21 +152,22 @@ export async function bootstrapGlobal(input: {
setGlobalStore: SetStoreFunction<GlobalStore> setGlobalStore: SetStoreFunction<GlobalStore>
queryClient: QueryClient queryClient: QueryClient
}) { }) {
const protocol = await input.protocol
const slow = [ const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)), protocol === "v1" && (() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.legacy))),
() => () =>
input.queryClient.fetchQuery( input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI), loadProvidersQuery(input.scope, null, input.serverAPI),
), ),
() => () =>
input.queryClient.fetchQuery( input.queryClient.fetchQuery(
loadPathQuery(input.scope, null, input.serverAPI.location, input.serverSDK, input.protocol), loadPathQuery(input.scope, null, input.serverAPI.location),
), ),
() => () =>
input.queryClient input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
.then((data) => input.setGlobalStore("project", data)), .then((data) => input.setGlobalStore("project", data)),
] ].filter(Boolean) as Array<() => Promise<unknown>>
await runAll(slow) await runAll(slow)
// showErrors({ // showErrors({
// errors: errors(), // errors: errors(),
@@ -273,22 +275,17 @@ export const loadPathQuery = (
scope: ServerScope, scope: ServerScope,
directory: string | null, directory: string | null,
api: LocationApi, api: LocationApi,
sdk: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) => ) =>
queryOptions<Path>({ queryOptions<Path>({
queryKey: [scope, directory, "path"], queryKey: [scope, directory, "path"],
queryFn: async () => { queryFn: () =>
if ((await protocol) === "v1") retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
return retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
state: "", state: "",
config: "", config: "",
worktree: location.project.directory, worktree: location.project.directory,
directory: location.directory, directory: location.directory,
home: "", home: "",
})) })),
},
}) })
export const loadReferencesQuery = ( export const loadReferencesQuery = (
@@ -307,7 +304,7 @@ export async function bootstrapDirectory(input: {
directory: string directory: string
scope: ServerScope scope: ServerScope
mcp: boolean mcp: boolean
sdk: OpencodeClient legacy: LegacyCapabilities
api: CatalogApi & { api: CatalogApi & {
readonly agent: AgentListApi readonly agent: AgentListApi
readonly command: CommandListApi readonly command: CommandListApi
@@ -355,35 +352,13 @@ export async function bootstrapDirectory(input: {
input.queryClient input.queryClient
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent)) .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
.then((data) => input.setStore("agent", data)), .then((data) => input.setStore("agent", data)),
() => (await input.protocol) === "v1" &&
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), (() =>
() => retry(() =>
retry(() => input.legacy.config
(async () => { .directory(input.directory)
if ((await input.protocol) !== "v1") return .then((config) => input.setStore("config", reconcile(config, { merge: false }))),
const x = await input.sdk.session.status() )),
if (!input.session) {
input.setStore("session_status", x.data!)
return
}
const statuses = x.data ?? {}
input.session.set(
"session_status",
produce((draft) => {
for (const sessionID of Object.keys(draft)) {
if (statuses[sessionID]) continue
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
}
}),
)
for (const [sessionID, status] of Object.entries(statuses)) {
input.session.set("session_status", sessionID, reconcile(status))
}
await Promise.all(
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
)
})(),
),
!seededProject && !seededProject &&
(() => (() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) => retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
@@ -393,21 +368,12 @@ export async function bootstrapDirectory(input: {
(() => (() =>
input.queryClient input.queryClient
.ensureQueryData( .ensureQueryData(
loadPathQuery(input.scope, input.directory, input.api.location, input.sdk, input.protocol), loadPathQuery(input.scope, input.directory, input.api.location),
) )
.then((data) => { .then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project) const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next) if (next) input.setStore("project", next)
})), })),
() =>
retry(async () => {
if ((await input.protocol) !== "v1") return
return input.sdk.vcs.get().then((result) => {
const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
})
}),
input.mcp && input.mcp &&
(() => (() =>
loadCommands(input.directory, input.api.command).then((commands) => loadCommands(input.directory, input.api.command).then((commands) =>
@@ -419,12 +385,10 @@ export async function bootstrapDirectory(input: {
), ),
() => () =>
retry(() => retry(() =>
(async () => { input.api.permission.request
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? [] .list({ location: { directory: input.directory } })
return input.api.permission.request .then((result) => result.data.map(normalizePermissionRequest))
.list({ location: { directory: input.directory } }) .then((permissions) => {
.then((result) => result.data.map(normalizePermissionRequest))
})().then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID) const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession( const grouped = groupBySession(
permissions.filter((permission) => !!permission.id && !!permission.sessionID), permissions.filter((permission) => !!permission.id && !!permission.sessionID),
@@ -455,12 +419,10 @@ export async function bootstrapDirectory(input: {
), ),
() => () =>
retry(() => retry(() =>
(async () => { input.api.question.request
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? [] .list({ location: { directory: input.directory } })
return input.api.question.request .then((result) => result.data)
.list({ location: { directory: input.directory } }) .then((questions) => {
.then((result) => result.data)
})().then((questions) => {
const ids = questions.map((question) => question.sessionID) const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession( const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[], questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
@@ -191,7 +191,10 @@ export function createChildStoreManager(input: {
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() })) const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() })) const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() })) const lspQuery = useQuery(() => {
const options = input.queryOptions.lsp(key)
return { ...options, enabled: options.enabled !== false && instanceQueriesEnabled() }
})
const providerQuery = useQuery(() => ({ const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key), ...input.queryOptions.providers(key),
enabled: instanceQueriesEnabled(), enabled: instanceQueriesEnabled(),
@@ -1,6 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise" import type { SessionApi } from "@opencode-ai/client/promise"
import { normalizeSessionInfo } from "@/utils/session" import { normalizeSessionInfo } from "@/utils/session"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) { export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
const result = await input.api.list({ const result = await input.api.list({
@@ -16,16 +15,6 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
} as const } as const
} }
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
try {
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
return { data: result.data, limit: input.limit, limited: true } as const
} catch {
const result = await input.client.session.list({ directory: input.directory, roots: true })
return { data: result.data, limit: input.limit, limited: false } as const
}
}
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) { export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
if (!input.limited) return input.count if (!input.limited) return input.count
if (input.count < input.limit) return input.count if (input.count < input.limit) return input.count
@@ -153,18 +153,6 @@ export function normalizeProviderList(
} }
} }
export function sanitizeProject(project: Project) {
if (!project.icon?.url && !project.icon?.override) return project
return {
...project,
icon: {
...project.icon,
url: undefined,
override: undefined,
},
}
}
export function normalizeProjectInfo(project: Project | CurrentProject): Project { export function normalizeProjectInfo(project: Project | CurrentProject): Project {
return { return {
...project, ...project,
+1 -31
View File
@@ -574,7 +574,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
void (async () => { void (async () => {
const sdk = serverSdk() const sdk = serverSdk()
if ((await sdk.protocol) !== "v1") return if ((await sdk.protocol) !== "v1") return
return sdk.client.project return sdk.legacy.project
.update({ projectID, directory: worktree, icon: { color } }) .update({ projectID, directory: worktree, icon: { color } })
.then((response) => response.data) .then((response) => response.data)
.then((result) => { .then((result) => {
@@ -753,9 +753,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
mobileSidebar: { mobileSidebar: {
opened: createMemo(() => store.mobileSidebar?.opened ?? false), opened: createMemo(() => store.mobileSidebar?.opened ?? false),
show() {
setStore("mobileSidebar", "opened", true)
},
hide() { hide() {
setStore("mobileSidebar", "opened", false) setStore("mobileSidebar", "opened", false)
}, },
@@ -961,33 +958,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (current.reviewOpen.includes(path)) return if (current.reviewOpen.includes(path)) return
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path) setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
}, },
closePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current) return
const index = current.indexOf(path)
if (index === -1) return
setStore(
"sessionView",
session,
"reviewOpen",
produce((draft) => {
if (!draft) return
draft.splice(index, 1)
}),
)
},
togglePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current || !current.includes(path)) {
this.openPath(path)
return
}
this.closePath(path)
},
}, },
} }
}, },
-3
View File
@@ -258,9 +258,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
} }
const list = async (directory: string) => { const list = async (directory: string) => {
if ((await input.sdk.protocol) === "v1") {
return (await input.sdk.client.permission.list({ directory })).data ?? []
}
return input.sdk.api.permission.request return input.sdk.api.permission.request
.list({ location: { directory } }) .list({ location: { directory } })
.then((result) => result.data.map(normalizePermissionRequest)) .then((result) => result.data.map(normalizePermissionRequest))
+16 -1
View File
@@ -12,7 +12,12 @@ import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global" import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" import {
createCompatibleApi,
createLegacyCapabilities,
type CompatibleApi,
type LegacyCapabilities,
} from "@/utils/server-compat"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
const isAbortError = (error: unknown) => const isAbortError = (error: unknown) =>
@@ -166,6 +171,7 @@ type ServerSDKBase = {
url: string url: string
client: ReturnType<typeof createSdkForServer> client: ReturnType<typeof createSdkForServer>
api: CompatibleApi api: CompatibleApi
legacy: LegacyCapabilities
currentApi: ServerApi currentApi: ServerApi
event: { event: {
on: ServerEventEmitter["on"] on: ServerEventEmitter["on"]
@@ -329,6 +335,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
directory, directory,
}) })
const api = createCompatibleApi({ protocol, current: currentApi, legacy }) const api = createCompatibleApi({ protocol, current: currentApi, legacy })
const capabilities = createLegacyCapabilities({ protocol, current: currentApi, legacy })
return { return {
server, server,
@@ -338,6 +345,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
url: server.http.url, url: server.http.url,
client: sdk, client: sdk,
api, api,
legacy: capabilities,
currentApi, currentApi,
event: { event: {
on: emitter.on.bind(emitter), on: emitter.on.bind(emitter),
@@ -365,6 +373,7 @@ export type DirectorySDK = {
client: OpencodeClient client: OpencodeClient
currentApi: ServerApi currentApi: ServerApi
api: CompatibleApi api: CompatibleApi
legacy: LegacyCapabilities
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>> event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
readonly url: string readonly url: string
createClient: ServerSDKBase["createClient"] createClient: ServerSDKBase["createClient"]
@@ -428,6 +437,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): Direc
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }), legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory, directory,
}), }),
legacy: createLegacyCapabilities({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
event: emitter, event: emitter,
get url() { get url() {
return serverSDK.url return serverSDK.url
+11 -5
View File
@@ -10,7 +10,7 @@ import type {
SessionStatus, SessionStatus,
Todo, Todo,
} from "@/types" } from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { LegacyCapabilities } from "@/utils/server-compat"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { batch } from "solid-js" import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
@@ -183,10 +183,14 @@ function reconcileFetched<T extends { id: string }>(
return [...result.values()].sort((a, b) => cmp(a.id, b.id)) return [...result.values()].sort((a, b) => cmp(a.id, b.id))
} }
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> } type ServerSessionOptions = {
retry?: typeof retry
protocol?: Promise<"v1" | "v2">
legacy?: LegacyCapabilities
}
export function createServerSession( export function createServerSession(
client: OpencodeClient, client: { session: Pick<LegacyCapabilities["session"], "get" | "messages" | "message"> },
sessionApiOrOptions?: SessionApi | ServerSessionOptions, sessionApiOrOptions?: SessionApi | ServerSessionOptions,
messageApi?: MessageApi, messageApi?: MessageApi,
currentOptions?: ServerSessionOptions, currentOptions?: ServerSessionOptions,
@@ -1389,14 +1393,16 @@ export function createServerSession(
touch(sessionID) touch(sessionID)
if (data.todo[sessionID] !== undefined && !request?.force) return if (data.todo[sessionID] !== undefined && !request?.force) return
if ((await options?.protocol) === "v2") { if ((await options?.protocol) === "v2") {
// TODO: Restore todos when the V2 API exposes a session todo snapshot.
setData("todo", sessionID, []) setData("todo", sessionID, [])
return return
} }
return runInflight(inflightTodo, sessionID, () => { return runInflight(inflightTodo, sessionID, () => {
const active = generation(sessionID) const active = generation(sessionID)
return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => { if (!options?.legacy) return Promise.resolve()
return (options.retry ?? retry)(() => options.legacy!.session.todo(sessionID)).then((result) => {
if (generations.get(sessionID) !== active) return if (generations.get(sessionID) !== active) return
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" })) setData("todo", sessionID, reconcile(result, { key: "id" }))
}) })
}) })
}, },
+24 -32
View File
@@ -5,7 +5,6 @@ import type {
ProviderAuthResponse, ProviderAuthResponse,
SessionStatus, SessionStatus,
} from "@/types" } from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@@ -60,6 +59,7 @@ import type {
import { toggleMcp } from "./global-sync/mcp" import { toggleMcp } from "./global-sync/mcp"
import { createServerSession, type ServerSession } from "./server-session" import { createServerSession, type ServerSession } from "./server-session"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import type { LegacyCapabilities } from "@/utils/server-compat"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -132,10 +132,11 @@ export const loadMcpResourcesQuery = (
placeholderData: {}, placeholderData: {},
}) })
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => export const loadLspQuery = (scope: ServerScope, directory: string, legacy: LegacyCapabilities, enabled = true) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "lsp"] as const, queryKey: [scope, directory, "lsp"] as const,
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), queryFn: () => legacy.lsp.status(directory),
enabled,
}) })
export const loadActiveSessionsQuery = ( export const loadActiveSessionsQuery = (
@@ -166,22 +167,20 @@ export function seedActiveSessionStatuses(
function makeQueryOptionsApi( function makeQueryOptionsApi(
scope: ServerScope, scope: ServerScope,
serverSDK: () => OpencodeClient,
serverAPI: ServerApi, serverAPI: ServerApi,
sdkFor: (dir: PathKey) => OpencodeClient, protocolKind: Accessor<"v1" | "v2" | undefined>,
protocol: Promise<"v1" | "v2">, legacy: LegacyCapabilities,
) { ) {
return { return {
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()), globalConfig: () => loadGlobalConfigQuery(scope, legacy, protocolKind() === "v1"),
projects: () => loadProjectsQuery(scope, serverAPI.project), projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI), providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
path: (directory: PathKey | null) => path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
loadPathQuery(scope, directory, serverAPI.location, directory ? sdkFor(directory) : serverSDK(), protocol),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference), references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp), mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp), mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), lsp: (directory: PathKey) => loadLspQuery(scope, directory, legacy, protocolKind() === "v1"),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
} }
} }
@@ -193,30 +192,24 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const owner = getOwner() const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner") if (!owner) throw new Error("ServerSync must be created within owner")
const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>() const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>() const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>() const sessionMeta = new Map<string, { limit: number }>()
const sdkFor = (directory: string) => { const session = createServerSession(
const key = directoryKey(directory) { session: serverSDK.legacy.session },
const cached = sdkCache.get(key) serverSDK.currentApi.session,
if (cached) return cached serverSDK.currentApi.message,
const sdk = serverSDK.createClient({ {
directory, protocol: serverSDK.protocol,
throwOnError: true, legacy: serverSDK.legacy,
}) },
sdkCache.set(key, sdk) )
return sdk
}
const session = createServerSession(serverSDK.client, serverSDK.currentApi.session, serverSDK.currentApi.message)
const queryOptionsApi = makeQueryOptionsApi( const queryOptionsApi = makeQueryOptionsApi(
serverSDK.scope, serverSDK.scope,
() => serverSDK.client,
serverSDK.currentApi, serverSDK.currentApi,
sdkFor, serverSDK.protocolKind,
serverSDK.protocol, serverSDK.legacy,
) )
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
@@ -293,7 +286,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryKey: [serverSDK.scope, "bootstrap"], queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => { queryFn: async () => {
await bootstrapGlobal({ await bootstrapGlobal({
serverSDK: serverSDK.client, legacy: serverSDK.legacy,
serverAPI: serverSDK.currentApi, serverAPI: serverSDK.currentApi,
protocol: serverSDK.protocol, protocol: serverSDK.protocol,
scope: serverSDK.scope, scope: serverSDK.scope,
@@ -349,7 +342,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const key = directoryKey(directory) const key = directoryKey(directory)
queue.clear(key) queue.clear(key)
sessionMeta.delete(key) sessionMeta.delete(key)
sdkCache.delete(key)
clearProviderRev(serverSDK.scope, key) clearProviderRev(serverSDK.scope, key)
}, },
translate: language.t, translate: language.t,
@@ -446,7 +438,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const child = children.ensureChild(directory) const child = children.ensureChild(directory)
const cache = children.vcsCache.get(key) const cache = children.vcsCache.get(key)
if (!cache) return if (!cache) return
const sdk = sdkFor(directory)
await bootstrapDirectory({ await bootstrapDirectory({
directory, directory,
scope: serverSDK.scope, scope: serverSDK.scope,
@@ -457,7 +448,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
project: globalStore.project, project: globalStore.project,
provider: globalStore.provider, provider: globalStore.provider,
}, },
sdk, legacy: serverSDK.legacy,
api: serverSDK.currentApi, api: serverSDK.currentApi,
store: child[0], store: child[0],
setStore: child[1], setStore: child[1],
@@ -577,6 +568,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
permission: session.data.permission, permission: session.data.permission,
vcsCache: children.vcsCache.get(key), vcsCache: children.vcsCache.get(key),
loadLsp: () => { loadLsp: () => {
if (serverSDK.protocolKind() !== "v1") return
if (!children.active(key)) return if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key)) void queryClient.fetchQuery(queryOptionsApi.lsp(key))
}, },
@@ -625,7 +617,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
} }
const updateConfigMutation = useMutation(() => ({ const updateConfigMutation = useMutation(() => ({
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }), mutationFn: (config: Config) => serverSDK.legacy.config.update(config),
onSuccess: () => { onSuccess: () => {
bootstrap.refetch() bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers // Invalidate all provider queries so newly configured custom providers
@@ -109,6 +109,7 @@ export function createHomeProjectsController(home: HomeController) {
home.server.context(conn).projects.move(worktree, index) home.server.context(conn).projects.move(worktree, index)
}, },
canReveal: canRevealProject, canReveal: canRevealProject,
canEdit: (conn: ServerConnection.Any) => home.server.context(conn).sdk.protocolKind() === "v1",
reveal: (conn: ServerConnection.Any, project: LocalProject) => { reveal: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.openPath || !canRevealProject(conn)) return if (!platform.openPath || !canRevealProject(conn)) return
platform.openPath(project.worktree).catch((cause: unknown) => platform.openPath(project.worktree).catch((cause: unknown) =>
@@ -40,6 +40,7 @@ export type HomeProjectsViewProps = {
canDefaultServer: Accessor<boolean> canDefaultServer: Accessor<boolean>
defaultServerKey: Accessor<ServerConnection.Key | null | undefined> defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
canRevealProject: (server: ServerConnection.Any) => boolean canRevealProject: (server: ServerConnection.Any) => boolean
canEditProject: (server: ServerConnection.Any) => boolean
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
onWheel: (event: WheelEvent) => void onWheel: (event: WheelEvent) => void
onChooseProject: (server: ServerConnection.Any) => void onChooseProject: (server: ServerConnection.Any) => void
@@ -548,9 +549,11 @@ function HomeProjectRow(
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}> <MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")} {props.language.t("command.session.new")}
</MenuV2.Item> </MenuV2.Item>
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}> <Show when={props.canEditProject(props.server)}>
{props.language.t("dialog.project.edit.title")} <MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
</MenuV2.Item> {props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
</Show>
<Show when={props.canRevealProject(props.server)}> <Show when={props.canRevealProject(props.server)}>
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}> <MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
{props.language.t( {props.language.t(
@@ -17,6 +17,7 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
canDefaultServer={props.projects.server.canDefault} canDefaultServer={props.projects.server.canDefault}
defaultServerKey={props.projects.server.defaultKey} defaultServerKey={props.projects.server.defaultKey}
canRevealProject={props.projects.project.canReveal} canRevealProject={props.projects.project.canReveal}
canEditProject={props.projects.project.canEdit}
unseenCount={props.projects.project.unseenCount} unseenCount={props.projects.project.unseenCount}
onWheel={props.scroll.viewport.containWheel} onWheel={props.scroll.viewport.containWheel}
onChooseProject={props.projects.project.choose} onChooseProject={props.projects.project.choose}
@@ -1,4 +1,4 @@
import type { Session } from "@/types" import type { Session, V2SessionListResponse } from "@/types"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMarked } from "@opencode-ai/ui/context/marked" import { useMarked } from "@opencode-ai/ui/context/marked"
@@ -69,7 +69,10 @@ export function createHomeSessionsController(home: HomeController) {
const cache = homeSessions() const cache = homeSessions()
const eventSequence = cache.eventSequence() const eventSequence = cache.eventSequence()
const index = await loadHomeSessionIndex( const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.client.v2.session.list(input, options), (input, options) =>
ctx.sdk.currentApi.session.list(input, options).then((data) => ({
data: data as unknown as V2SessionListResponse,
})),
eventSequence, eventSequence,
signal, signal,
) )
@@ -179,6 +182,7 @@ export function createHomeSessionsController(home: HomeController) {
showProjectName: () => !home.project.selected(), showProjectName: () => !home.project.selected(),
server: () => home.selection.value().server, server: () => home.selection.value().server,
canCreate: () => !!home.project.newSession(), canCreate: () => !!home.project.newSession(),
canArchive: () => home.server.focusedContext()?.sdk.protocolKind() === "v1",
create: home.project.openNewSession, create: home.project.openNewSession,
open: (session: Session, options?: OpenSessionOptions) => { open: (session: Session, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.directory) const directoryKey = pathKey(session.directory)
@@ -211,16 +215,10 @@ export function createHomeSessionsController(home: HomeController) {
const ctx = home.server.focusedContext() const ctx = home.server.focusedContext()
if (!conn || !ctx) return if (!conn || !ctx) return
const [, setStore] = ctx.sync.child(session.directory) const [, setStore] = ctx.sync.child(session.directory)
if ((await ctx.sdk.protocol) !== "v1") return
await archiveHomeSession({ await archiveHomeSession({
server: ServerConnection.key(conn), server: ServerConnection.key(conn),
session, session,
archive: (sessionID) => archive: (sessionID) => ctx.sdk.legacy.session.archive(sessionID, session.directory),
ctx.sdk.client.session.update({
sessionID,
directory: session.directory,
time: { archived: Date.now() },
}),
remove: () => remove: () =>
setStore( setStore(
produce((draft) => { produce((draft) => {
@@ -43,6 +43,7 @@ export type HomeSessionsViewProps = {
showProjectName: Accessor<boolean> showProjectName: Accessor<boolean>
server: Accessor<ServerConnection.Key> server: Accessor<ServerConnection.Key>
canCreateSession: Accessor<boolean> canCreateSession: Accessor<boolean>
canArchiveSession: Accessor<boolean>
searchValue: Accessor<string> searchValue: Accessor<string>
searchPlaceholder: Accessor<string> searchPlaceholder: Accessor<string>
searchOpen: Accessor<boolean> searchOpen: Accessor<boolean>
@@ -460,7 +461,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
group-hover/session:opacity-100 focus-within:opacity-100 group-hover/session:opacity-100 focus-within:opacity-100
`} `}
> >
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}> <Show when={props.canArchiveSession()}>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<IconButtonV2 <IconButtonV2
data-action="home-session-archive" data-action="home-session-archive"
variant="ghost-muted" variant="ghost-muted"
@@ -473,7 +475,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
void props.onArchiveSession(props.record.session) void props.onArchiveSession(props.record.session)
}} }}
/> />
</TooltipV2> </TooltipV2>
</Show>
</div> </div>
</Show> </Show>
</div> </div>
@@ -16,6 +16,7 @@ export function HomeSessions(props: {
showProjectName={props.sessions.session.showProjectName} showProjectName={props.sessions.session.showProjectName}
server={props.sessions.session.server} server={props.sessions.session.server}
canCreateSession={props.sessions.session.canCreate} canCreateSession={props.sessions.session.canCreate}
canArchiveSession={props.sessions.session.canArchive}
searchValue={props.search.query.value} searchValue={props.search.query.value}
searchPlaceholder={props.search.query.placeholder} searchPlaceholder={props.search.query.placeholder}
searchOpen={props.search.query.open} searchOpen={props.search.query.open}
+27 -37
View File
@@ -872,17 +872,12 @@ export default function LegacyLayout(props: ParentProps) {
} }
async function archiveSession(session: Session) { async function archiveSession(session: Session) {
if ((await serverSDK().protocol) !== "v1") return
const [store, setStore] = serverSync().child(session.directory) const [store, setStore] = serverSync().child(session.directory)
const sessions = store.session ?? [] const sessions = store.session ?? []
const index = sessions.findIndex((s) => s.id === session.id) const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1] const nextSession = sessions[index + 1] ?? sessions[index - 1]
await serverSDK().client.session.update({ await serverSDK().legacy.session.archive(session.id, session.directory)
sessionID: session.id,
directory: session.directory,
time: { archived: Date.now() },
})
setStore( setStore(
produce((draft) => { produce((draft) => {
const match = Binary.search(draft.session, session.id, (s) => s.id) const match = Binary.search(draft.session, session.id, (s) => s.id)
@@ -980,6 +975,7 @@ export default function LegacyLayout(props: ParentProps) {
title: language.t("command.session.archive"), title: language.t("command.session.archive"),
category: language.t("command.category.session"), category: language.t("command.category.session"),
keybind: "mod+shift+backspace", keybind: "mod+shift+backspace",
hidden: serverSDK().protocolKind() !== "v1",
disabled: !params.dir || !params.id, disabled: !params.dir || !params.id,
onSelect: () => { onSelect: () => {
const session = currentSessions().find((s) => s.id === params.id) const session = currentSessions().find((s) => s.id === params.id)
@@ -1304,13 +1300,10 @@ export default function LegacyLayout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next const name = next === getFilename(project.worktree) ? "" : next
if (project.id && project.id !== "global") { if (project.id && project.id !== "global") {
const sdk = serverSDK() const result = await serverSDK().legacy.project
if ((await sdk.protocol) !== "v1") return
const result = await sdk.client.project
.update({ projectID: project.id, directory: project.worktree, name }) .update({ projectID: project.id, directory: project.worktree, name })
.then((response) => response.data) .then((response) => response.data)
if (!result) return if (!result) return
// const result = await serverSDK().api.project.update({ projectID: project.id, name })
serverSync().set("project", (items) => serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)), items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
) )
@@ -1477,12 +1470,8 @@ export default function LegacyLayout(props: ParentProps) {
platform, platform,
serverSDK().scope, serverSDK().scope,
) )
await serverSDK()
.client.instance.dispose({ directory })
.catch(() => undefined)
const result = await serverSDK() const result = await serverSDK()
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } }) .legacy.workspace.reset(root, directory)
.then((x) => x.data) .then((x) => x.data)
.catch((err) => { .catch((err) => {
showToast({ showToast({
@@ -1504,11 +1493,7 @@ export default function LegacyLayout(props: ParentProps) {
.filter((session) => session.time.archived === undefined) .filter((session) => session.time.archived === undefined)
.map((session) => .map((session) =>
serverSDK() serverSDK()
.client.session.update({ .legacy.session.archive(session.id, session.directory)
sessionID: session.id,
directory: session.directory,
time: { archived: Date.now() },
})
.catch(() => undefined), .catch(() => undefined),
), ),
) )
@@ -1895,6 +1880,8 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon, clearHoverProjectSoon,
prefetchSession, prefetchSession,
archiveSession, archiveSession,
canArchive: () => serverSDK().protocolKind() === "v1",
canResetWorkspace: () => serverSDK().protocolKind() === "v1",
workspaceName, workspaceName,
renameWorkspace, renameWorkspace,
editorOpen, editorOpen,
@@ -1931,6 +1918,7 @@ export default function LegacyLayout(props: ParentProps) {
openSidebar: () => layout.sidebar.open(), openSidebar: () => layout.sidebar.open(),
closeProject, closeProject,
showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj), showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
canEditProject: () => serverSDK().protocolKind() === "v1",
toggleProjectWorkspaces, toggleProjectWorkspaces,
workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(), workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
workspaceIds, workspaceIds,
@@ -1941,6 +1929,7 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon, clearHoverProjectSoon,
prefetchSession, prefetchSession,
archiveSession, archiveSession,
canArchive: () => serverSDK().protocolKind() === "v1",
}, },
} }
@@ -2031,16 +2020,19 @@ export default function LegacyLayout(props: ParentProps) {
<div class="shrink-0 pl-1 py-1"> <div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0"> <div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0"> <div class="flex flex-col min-w-0">
<InlineEditor <Show
id={`project:${projectId()}`} when={serverSDK().protocolKind() === "v1" || !project.id || project.id === "global"}
value={projectName} fallback={<span class="text-14-medium text-text-strong truncate">{projectName()}</span>}
onSave={(next) => { >
void renameProject(project, next) <InlineEditor
}} id={`project:${projectId()}`}
class="text-14-medium text-text-strong truncate" value={projectName}
displayClass="text-14-medium text-text-strong truncate" onSave={(next) => void renameProject(project, next)}
stopPropagation class="text-14-medium text-text-strong truncate"
/> displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
</Show>
<Tooltip <Tooltip
placement="bottom" placement="bottom"
@@ -2075,13 +2067,11 @@ export default function LegacyLayout(props: ParentProps) {
/> />
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1"> <DropdownMenu.Content class="mt-1">
<DropdownMenu.Item <Show when={serverSDK().protocolKind() === "v1"}>
onSelect={() => { <DropdownMenu.Item onSelect={() => showEditProjectDialog(server.current!, project)}>
showEditProjectDialog(server.current!, project) <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
}} </DropdownMenu.Item>
> </Show>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
data-action="project-workspaces-toggle" data-action="project-workspaces-toggle"
data-project={slug()} data-project={slug()}
@@ -87,6 +87,7 @@ export type SessionItemProps = {
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void> archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
} }
const SessionRow = (props: { const SessionRow = (props: {
@@ -241,7 +242,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
</Show> </Show>
</div> </div>
<Show when={!props.level}> <Show when={!props.level && props.canArchive()}>
<div <div
class="shrink-0 overflow-hidden transition-[width,opacity]" class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{ classList={{
@@ -27,6 +27,7 @@ export type ProjectSidebarContext = {
openSidebar: () => void openSidebar: () => void
closeProject: (directory: string) => void closeProject: (directory: string) => void
showEditProjectDialog: (project: LocalProject) => void showEditProjectDialog: (project: LocalProject) => void
canEditProject: Accessor<boolean>
toggleProjectWorkspaces: (project: LocalProject) => void toggleProjectWorkspaces: (project: LocalProject) => void
workspacesEnabled: (project: LocalProject) => boolean workspacesEnabled: (project: LocalProject) => boolean
workspaceIds: (project: LocalProject) => string[] workspaceIds: (project: LocalProject) => string[]
@@ -65,6 +66,7 @@ const ProjectTile = (props: {
onProjectFocus: (worktree: string) => void onProjectFocus: (worktree: string) => void
navigateToProject: (directory: string) => void navigateToProject: (directory: string) => void
showEditProjectDialog: (project: LocalProject) => void showEditProjectDialog: (project: LocalProject) => void
canEditProject: Accessor<boolean>
toggleProjectWorkspaces: (project: LocalProject) => void toggleProjectWorkspaces: (project: LocalProject) => void
workspacesEnabled: (project: LocalProject) => boolean workspacesEnabled: (project: LocalProject) => boolean
closeProject: (directory: string) => void closeProject: (directory: string) => void
@@ -148,9 +150,11 @@ const ProjectTile = (props: {
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Portal> <ContextMenu.Portal>
<ContextMenu.Content> <ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}> <Show when={props.canEditProject()}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel> <ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
</ContextMenu.Item> <ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</Show>
<ContextMenu.Item <ContextMenu.Item
data-action="project-workspaces-toggle" data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)} data-project={base64Encode(props.project.worktree)}
@@ -331,6 +335,7 @@ export const SortableProject = (props: {
onProjectFocus={props.ctx.onProjectFocus} onProjectFocus={props.ctx.onProjectFocus}
navigateToProject={props.ctx.navigateToProject} navigateToProject={props.ctx.navigateToProject}
showEditProjectDialog={props.ctx.showEditProjectDialog} showEditProjectDialog={props.ctx.showEditProjectDialog}
canEditProject={props.ctx.canEditProject}
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces} toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
workspacesEnabled={props.ctx.workspacesEnabled} workspacesEnabled={props.ctx.workspacesEnabled}
closeProject={props.ctx.closeProject} closeProject={props.ctx.closeProject}
@@ -42,6 +42,8 @@ export type WorkspaceSidebarContext = {
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void> archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
canResetWorkspace: Accessor<boolean>
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
editorOpen: (id: string) => boolean editorOpen: (id: string) => boolean
@@ -151,6 +153,7 @@ const WorkspaceActions = (props: {
workspaceValue: Accessor<string> workspaceValue: Accessor<string>
openEditor: WorkspaceSidebarContext["openEditor"] openEditor: WorkspaceSidebarContext["openEditor"]
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"] showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
canResetWorkspace: WorkspaceSidebarContext["canResetWorkspace"]
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"] showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
root: string root: string
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"] clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
@@ -199,12 +202,14 @@ const WorkspaceActions = (props: {
> >
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <Show when={props.canResetWorkspace()}>
disabled={props.local() || props.busy()} <DropdownMenu.Item
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)} disabled={props.local() || props.busy()}
> onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel> >
</DropdownMenu.Item> <DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item <DropdownMenu.Item
disabled={props.local() || props.busy()} disabled={props.local() || props.busy()}
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)} onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
@@ -272,6 +277,7 @@ const WorkspaceSessionList = (props: {
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession} prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession} archiveSession={props.ctx.archiveSession}
canArchive={props.ctx.canArchive}
/> />
)} )}
</For> </For>
@@ -416,6 +422,7 @@ export const SortableWorkspace = (props: {
workspaceValue={workspaceValue} workspaceValue={workspaceValue}
openEditor={props.ctx.openEditor} openEditor={props.ctx.openEditor}
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog} showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
canResetWorkspace={props.ctx.canResetWorkspace}
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog} showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
root={props.project.worktree} root={props.project.worktree}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+15 -9
View File
@@ -52,7 +52,7 @@ import { useNotification } from "@/context/notification"
import { PromptProvider, usePrompt } from "@/context/prompt" import { PromptProvider, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { SDKProvider, useSDK } from "@/context/sdk" import { SDKProvider, useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk" import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { ServerConnection, serverName, useServer } from "@/context/server" import { ServerConnection, serverName, useServer } from "@/context/server"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
@@ -361,6 +361,7 @@ export default function Page() {
const language = useLanguage() const language = useLanguage()
const sdk = useSDK() const sdk = useSDK()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const protocol = useServerProtocol()
const settings = useSettings() const settings = useSettings()
const platform = usePlatform() const platform = usePlatform()
const prompt = usePrompt() const prompt = usePrompt()
@@ -847,7 +848,7 @@ export default function Page() {
} }
const gitMutation = useMutation(() => ({ const gitMutation = useMutation(() => ({
mutationFn: () => sdk().client.project.initGit(), mutationFn: () => sdk().legacy.project.initGit(sdk().directory),
onSuccess: (x) => { onSuccess: (x) => {
if (!x.data) return if (!x.data) return
upsert(x.data) upsert(x.data)
@@ -896,17 +897,19 @@ export default function Page() {
() => { () => {
const id = params.id const id = params.id
return [ return [
protocol(),
sdk().directory, sdk().directory,
id, id,
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle", id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
id ? composer.blocked() : false, id ? composer.blocked() : false,
] as const ] as const
}, },
([dir, id, status, blocked]) => { ([serverProtocol, dir, id, status, blocked]) => {
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer) if (todoTimer !== undefined) window.clearTimeout(todoTimer)
todoFrame = undefined todoFrame = undefined
todoTimer = undefined todoTimer = undefined
if (serverProtocol !== "v1") return
if (!id) return if (!id) return
if (status === "idle" && !blocked) return if (status === "idle" && !blocked) return
const cached = untrack(() => sync().data.todo[id] !== undefined) const cached = untrack(() => sync().data.todo[id] !== undefined)
@@ -1217,11 +1220,13 @@ export default function Page() {
{language.t("session.review.noVcs.createGit.description")} {language.t("session.review.noVcs.createGit.description")}
</div> </div>
</div> </div>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}> <Show when={protocol() === "v1"}>
{gitMutation.isPending <Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
? language.t("session.review.noVcs.createGit.actionLoading") {gitMutation.isPending
: language.t("session.review.noVcs.createGit.action")} ? language.t("session.review.noVcs.createGit.actionLoading")
</Button> : language.t("session.review.noVcs.createGit.action")}
</Button>
</Show>
</div> </div>
) )
@@ -1254,7 +1259,8 @@ export default function Page() {
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div> return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
} }
if (reviewMode() === "turn" && nogit()) { if (reviewMode() === "turn" && nogit()) {
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} /> if (protocol() === "v1") return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
return empty(language.t("session.review.noVcs.createGit.description"))
} }
return <SessionReviewEmptyChangesV2 /> return <SessionReviewEmptyChangesV2 />
} }
@@ -22,8 +22,6 @@ type TabsInput = {
fileBrowser?: Accessor<boolean> fileBrowser?: Accessor<boolean>
} }
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) { export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
return input.opened && input.visible return input.opened && input.visible
} }
@@ -62,7 +62,7 @@ import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout" import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk" import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
@@ -302,7 +302,8 @@ export function MessageTimeline(props: {
return displayLabel(session) return displayLabel(session)
}) })
const shareUrl = createMemo(() => info()?.share?.url) const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled") const protocol = useServerProtocol()
const shareEnabled = createMemo(() => protocol() === "v1" && sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID) const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => { const parent = createMemo(() => {
const id = parentID() const id = parentID()
@@ -665,14 +666,14 @@ export function MessageTimeline(props: {
} }
const shareMutation = useMutation(() => ({ const shareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }), mutationFn: (id: string) => serverSDK().legacy.session.share(id),
onError: (err) => { onError: (err) => {
console.error("Failed to share session", err) console.error("Failed to share session", err)
}, },
})) }))
const unshareMutation = useMutation(() => ({ const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }), mutationFn: (id: string) => serverSDK().legacy.session.unshare(id),
onError: (err) => { onError: (err) => {
console.error("Failed to unshare session", err) console.error("Failed to unshare session", err)
}, },
@@ -818,14 +819,12 @@ export function MessageTimeline(props: {
const archiveSession = async (sessionID: string) => { const archiveSession = async (sessionID: string) => {
const session = sync().session.get(sessionID) const session = sync().session.get(sessionID)
if (!session) return if (!session) return
if ((await sdk().protocol) !== "v1") return
const sessions = sync().data.session ?? [] const sessions = sync().data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID) const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk() await sdk()
.client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } }) .legacy.session.archive(sessionID, sdk().directory)
.then(() => { .then(() => {
sync().set( sync().set(
produce((draft) => { produce((draft) => {
@@ -1574,9 +1573,11 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}> <Show when={protocol() === "v1"}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel> <DropdownMenu.Item onSelect={() => void archiveSession(id)}>
</DropdownMenu.Item> <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)} onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
@@ -1645,9 +1646,11 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}... {language.t("session.share.action.share")}...
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
<MenuV2.Item onSelect={() => void archiveSession(id)}> <Show when={protocol() === "v1"}>
{language.t("common.archive")} <MenuV2.Item onSelect={() => void archiveSession(id)}>
</MenuV2.Item> {language.t("common.archive")}
</MenuV2.Item>
</Show>
<MenuV2.Separator /> <MenuV2.Separator />
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}> <MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
{language.t("common.delete")}... {language.t("common.delete")}...
@@ -19,6 +19,7 @@ import type { UserMessage } from "@/types"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership" import { createSessionOwnership } from "./session-ownership"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useServerProtocol } from "@/context/server-sdk"
export type SessionCommandContext = { export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void navigateMessageByOffset: (offset: number) => void
@@ -43,6 +44,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const permission = usePermission() const permission = usePermission()
const prompt = usePrompt() const prompt = usePrompt()
const sdk = useSDK() const sdk = useSDK()
const protocol = useServerProtocol()
const settings = useSettings() const settings = useSettings()
const sync = useSync() const sync = useSync()
const terminal = useTerminal() const terminal = useTerminal()
@@ -194,7 +196,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
} }
const url = await sdk() const url = await sdk()
.client.session.share({ sessionID }) .legacy.session.share(sessionID)
.then((res) => res.data?.share?.url) .then((res) => res.data?.share?.url)
.catch(() => undefined) .catch(() => undefined)
if (!url) { if (!url) {
@@ -214,7 +216,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!sessionID) return if (!sessionID) return
await sdk() await sdk()
.client.session.unshare({ sessionID }) .legacy.session.unshare(sessionID)
.then(() => .then(() =>
showToast({ showToast({
title: language.t("toast.session.unshare.success.title"), title: language.t("toast.session.unshare.success.title"),
@@ -377,6 +379,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
} }
const shareCmds = () => { const shareCmds = () => {
if (protocol() !== "v1") return []
if (sync().data.config.share === "disabled") return [] if (sync().data.config.share === "disabled") return []
return [ return [
sessionCommand({ sessionCommand({
+21 -1
View File
@@ -4,7 +4,10 @@ import { createCompatibleApi } from "./server-compat"
function setup( function setup(
protocol: "v1" | "v2" | Promise<"v1" | "v2">, protocol: "v1" | "v2" | Promise<"v1" | "v2">,
responses?: { vcs?: { branch: string; default_branch: string } }, responses?: {
vcs?: { branch: string; default_branch: string }
question?: { id: string; sessionID: string; questions: never[]; tool?: { messageID: string; callID: string } }[]
},
) { ) {
const requests: Request[] = [] const requests: Request[] = []
const fetcher = Object.assign( const fetcher = Object.assign(
@@ -36,6 +39,8 @@ function setup(
} }
if (request.method === "GET" && new URL(request.url).pathname === "/vcs") if (request.method === "GET" && new URL(request.url).pathname === "/vcs")
return Response.json(responses?.vcs ?? {}) return Response.json(responses?.vcs ?? {})
if (request.method === "GET" && new URL(request.url).pathname === "/question")
return Response.json(responses?.question ?? [])
if (request.method === "GET") return Response.json([]) if (request.method === "GET") return Response.json([])
return new Response(undefined, { status: 204 }) return new Response(undefined, { status: 204 })
}, },
@@ -163,6 +168,21 @@ describe("createCompatibleApi", () => {
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session") expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
}) })
test("translates V1 question tool call IDs", async () => {
const { api } = setup("v1", {
question: [
{
id: "que_1",
sessionID: "ses_1",
questions: [],
tool: { messageID: "msg_1", callID: "call_1" },
},
],
})
expect((await api.question.request.list()).data[0]?.tool).toEqual({ messageID: "msg_1", id: "call_1" })
})
/* /*
test("projects the V1 default branch", async () => { test("projects the V1 default branch", async () => {
const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } }) const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
+140 -34
View File
@@ -3,7 +3,6 @@ import type { ServerProtocol } from "./server-protocol"
import type { AgentPartInput, FilePartInput, Session, TextPartInput } from "@/types" import type { AgentPartInput, FilePartInput, Session, TextPartInput } from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { import type {
Project,
ProjectCurrent, ProjectCurrent,
SessionApi, SessionApi,
SessionCommandInput, SessionCommandInput,
@@ -28,7 +27,6 @@ type CompatibleSessionApi = Omit<
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput> shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput> compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]> rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
// archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]> remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
} }
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & { type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
@@ -54,6 +52,99 @@ type CompatibleInput = {
directory?: string directory?: string
} }
export function createLegacyCapabilities(input: CompatibleInput) {
const directory = (value?: string) => value ?? input.directory
const client = (value?: string) => input.legacy(directory(value))
const requireV1 = async () => {
if ((await input.protocol) !== "v1") throw new Error("This capability is unavailable on V2 servers")
}
return {
config: {
global: async () => {
await requireV1()
return (await client().global.config.get()).data ?? {}
},
directory: async (value?: string) => {
await requireV1()
return (await client(value).config.get()).data ?? {}
},
update: async (config: NonNullable<Parameters<LegacyClient["global"]["config"]["update"]>[0]>["config"]) => {
await requireV1()
return client().global.config.update({ config })
},
},
auth: {
set: async (value: Parameters<LegacyClient["auth"]["set"]>[0]) => {
await requireV1()
return client().auth.set(value)
},
remove: async (value: Parameters<LegacyClient["auth"]["remove"]>[0]) => {
await requireV1()
return client().auth.remove(value)
},
},
session: {
get: (value: Parameters<LegacyClient["session"]["get"]>[0]) => client().session.get(value),
messages: (value: Parameters<LegacyClient["session"]["messages"]>[0]) => client().session.messages(value),
message: (value: Parameters<LegacyClient["session"]["message"]>[0]) => client().session.message(value),
share: async (sessionID: string) => {
await requireV1()
return client().session.share({ sessionID })
},
unshare: async (sessionID: string) => {
await requireV1()
return client().session.unshare({ sessionID })
},
archive: async (sessionID: string, value?: string) => {
await requireV1()
return client(value).session.update({ sessionID, time: { archived: Date.now() } })
},
todo: async (sessionID: string, value?: string) => {
await requireV1()
return (await client(value).session.todo({ sessionID })).data ?? []
},
},
project: {
update: async (value: Parameters<LegacyClient["project"]["update"]>[0]) => {
await requireV1()
return client(value.directory).project.update(value)
},
initGit: async (value?: string) => {
await requireV1()
return client(value).project.initGit()
},
},
workspace: {
reset: async (root: string, value: string) => {
await requireV1()
await client(value).instance.dispose().catch(() => undefined)
return client(root).worktree.reset({ worktreeResetInput: { directory: value } })
},
},
pty: {
shells: async () => {
await requireV1()
return (await client().pty.shells()).data ?? []
},
},
path: {
get: async (value?: string) => {
await requireV1()
return (await client(value).path.get()).data
},
},
lsp: {
status: async (value: string) => {
await requireV1()
return (await client(value).lsp.status()).data ?? []
},
},
}
}
export type LegacyCapabilities = ReturnType<typeof createLegacyCapabilities>
function mime(uri: string) { function mime(uri: string) {
const match = /^data:([^;,]+)/.exec(uri) const match = /^data:([^;,]+)/.exec(uri)
return match?.[1] ?? "application/octet-stream" return match?.[1] ?? "application/octet-stream"
@@ -184,9 +275,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) { async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title }) await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
}, },
// async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
// await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
// },
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) { async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
await legacy(value).session.delete(value) await legacy(value).session.delete(value)
}, },
@@ -313,34 +401,28 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
canonical: result.data.worktree, canonical: result.data.worktree,
} satisfies ProjectCurrent } satisfies ProjectCurrent
}, },
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
// const result = await legacy({ directory: project?.worktree }).project.update({
// ...value,
// directory: project?.worktree,
// })
// if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
// return result.data as Project
// },
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) { async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
const result = await legacy(value.location).worktree.list() const result = await legacy(value.location).worktree.list()
return (result.data ?? []).map((item) => ({ directory: item })) return (result.data ?? []).map((item) => ({ directory: item }))
}, },
}, },
// path: { location: {
// ...input.current.path, ...input.current.location,
// async get(value?: Parameters<ServerApi["path"]["get"]>[0]) { async get(value?: Parameters<ServerApi["location"]["get"]>[0]) {
// const result = await legacy(value?.location).path.get() const result = await legacy(value?.location).path.get()
// if (!result.data) throw new Error("Path unavailable") if (!result.data) throw new Error("Location unavailable")
// return result.data return {
// }, directory: result.data.directory,
// }, project: {
id: "",
directory: result.data.worktree,
canonical: result.data.worktree,
},
}
},
},
vcs: { vcs: {
...input.current.vcs, ...input.current.vcs,
// async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
// const result = await legacy(value?.location).vcs.get()
// return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
// },
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) { async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
const result = await legacy(value?.location).vcs.status() const result = await legacy(value?.location).vcs.status()
return located(result.data ?? [], value?.location) return located(result.data ?? [], value?.location)
@@ -456,9 +538,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
}, },
pty: { pty: {
...input.current.pty, ...input.current.pty,
// async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
// return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
// },
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) { async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location) return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
}, },
@@ -490,14 +569,29 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) { async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
await legacy(value.location).pty.remove({ ptyID: value.ptyID }) await legacy(value.location).pty.remove({ ptyID: value.ptyID })
}, },
// async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
// const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
// if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
// return located(result.data, value.location)
// },
}, },
permission: { permission: {
...input.current.permission, ...input.current.permission,
request: {
...input.current.permission.request,
async list(value?: Parameters<ServerApi["permission"]["request"]["list"]>[0]) {
const result = await legacy(value?.location).permission.list()
return located(
(result.data ?? []).map((item) => ({
id: item.id,
sessionID: item.sessionID,
action: item.permission,
resources: item.patterns,
metadata: item.metadata,
save: item.always,
source: item.tool
? { type: "tool" as const, messageID: item.tool.messageID, callID: item.tool.callID }
: undefined,
})),
value?.location,
) as Awaited<ReturnType<ServerApi["permission"]["request"]["list"]>>
},
},
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } }) { async reply(value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } }) {
await legacy(value.location).permission.respond({ await legacy(value.location).permission.respond({
sessionID: value.sessionID, sessionID: value.sessionID,
@@ -509,6 +603,18 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
}, },
question: { question: {
...input.current.question, ...input.current.question,
request: {
...input.current.question.request,
async list(value?: Parameters<ServerApi["question"]["request"]["list"]>[0]) {
return located(
((await legacy(value?.location).question.list()).data ?? []).map((request) => ({
...request,
tool: request.tool && { messageID: request.tool.messageID, id: request.tool.callID },
})),
value?.location,
)
},
},
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) { async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
await legacy().question.reply({ await legacy().question.reply({
requestID: value.requestID, requestID: value.requestID,
+1 -1
View File
@@ -61,7 +61,7 @@ for (const item of targets) {
name: "parcel-watcher-binding", name: "parcel-watcher-binding",
setup(build) { setup(build) {
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({ 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", loader: "js",
})) }))
}, },
+193 -38
View File
@@ -1,4 +1,4 @@
import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk" import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
import type { import type {
EventSubscribeOutput, EventSubscribeOutput,
OpenCodeClient, OpenCodeClient,
@@ -37,6 +37,34 @@ export type TurnStart =
| { readonly type: "skill"; readonly id: string } | { readonly type: "skill"; readonly id: string }
| { readonly type: "compaction"; readonly id: string } | { readonly type: "compaction"; readonly id: string }
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
export const ChildSessionUpdateMethod = "opencode/session/child_update"
type ChildSessionUpdateBase = {
readonly rootSessionId: string
readonly childSessionId: string
readonly parentSessionId: string
readonly depth: number
readonly title?: string
}
type ChildSessionEvent =
| { readonly type: "update"; readonly update: SessionUpdate }
| {
readonly type: "status"
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
readonly error?: { readonly type: string; readonly message: string }
}
export type ChildSessionUpdate = ChildSessionUpdateBase & ChildSessionEvent
type ChildSession = {
readonly id: string
readonly parentID: string
readonly depth: number
readonly title?: string
}
function emptyToolState(): ToolState { function emptyToolState(): ToolState {
return { name: "tool", input: {}, metadata: {}, content: [] } return { name: "tool", input: {}, metadata: {}, content: [] }
} }
@@ -50,8 +78,13 @@ export async function streamTurn(input: {
readonly writeTextFile: boolean readonly writeTextFile: boolean
readonly submit: (signal: AbortSignal) => Promise<unknown> readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl readonly control: TurnControl
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
readonly connectionSignal?: AbortSignal
readonly sessionSignal?: AbortSignal
}): Promise<PromptResponse> { }): Promise<PromptResponse> {
const streamController = new AbortController() const streamController = new AbortController()
const connectionAbort = () => streamController.abort()
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]() const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
const connected = await stream.next() const connected = await stream.next()
if (connected.done) throw new Error("event stream disconnected before prompt admission") if (connected.done) throw new Error("event stream disconnected before prompt admission")
@@ -62,47 +95,101 @@ export async function streamTurn(input: {
let finish: SessionMessageAssistant["finish"] let finish: SessionMessageAssistant["finish"]
let executionError: { readonly type: string; readonly message: string } | undefined let executionError: { readonly type: string; readonly message: string } | undefined
const tools = new Map<string, ToolState>() const tools = new Map<string, ToolState>()
const children = new Map<string, ChildSession>()
const openChildren = new Set<string>()
let handedOff = false
const update = (value: Parameters<Connection["sessionUpdate"]>[0]["update"]) => const notifyChild = async (child: ChildSession, value: ChildSessionEvent) => {
input.connection.sessionUpdate({ sessionId: input.sessionID, update: value }) if (!input.childSessionUpdate) return
await input
.childSessionUpdate({
rootSessionId: input.sessionID,
childSessionId: child.id,
parentSessionId: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
...value,
})
.catch(() => {})
}
const consume = async () => { const updateSession = async (value: SessionUpdate, child: ChildSession | undefined, mode: "turn" | "background") => {
const projected = child ? projectChildUpdate(value, child) : value
if (mode === "turn" && (!child || !input.childSessionUpdate)) {
await input.connection.sessionUpdate({ sessionId: input.sessionID, update: projected })
}
if (child) await notifyChild(child, { type: "update", update: projected })
}
const consume = async (mode: "turn" | "background") => {
while (!streamController.signal.aborted) { while (!streamController.signal.aborted) {
const next = await stream.next() const next = await stream.next()
if (next.done) throw new Error("event stream disconnected during prompt execution") if (next.done) throw new Error("event stream disconnected during prompt execution")
const event = next.value const event = next.value
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) { if (event.type === "session.created") {
const tool = event.data.source?.id ? tools.get(event.data.source.id) : undefined const parentID = event.data.parentID
if (!parentID) continue
const parent = parentID === input.sessionID ? undefined : children.get(parentID)
if ((mode === "turn" && parentID === input.sessionID) || parent) {
const child = {
id: event.data.sessionID,
parentID,
depth: parent ? parent.depth + 1 : 1,
title: event.data.title,
}
children.set(child.id, child)
openChildren.add(child.id)
await notifyChild(child, { type: "status", status: "created" })
}
continue
}
const eventSessionID = sessionIDFromEvent(event)
const child = eventSessionID ? children.get(eventSessionID) : undefined
const send = (update: SessionUpdate) => updateSession(update, child, mode)
if (mode === "background" && !child) continue
if (event.type === "permission.asked" && (event.data.sessionID === input.sessionID || child)) {
const tool = event.data.source?.id ? tools.get(toolKey(event.data.sessionID, event.data.source.id)) : undefined
await replyPermission({ await replyPermission({
client: input.client, client: input.client,
connection: input.connection, connection: input.connection,
event, event,
sessionID: input.sessionID, sessionID: event.data.sessionID,
clientSessionID: input.sessionID,
cwd: input.cwd, cwd: input.cwd,
tool, tool,
...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}),
}) })
continue continue
} }
if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) { if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
await input.client.form await input.client.form
.cancel({ sessionID: input.sessionID, formID: event.data.form.id }) .cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})) .catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
continue continue
} }
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue
if (matchesStart(event, input.start)) { if (matchesStart(event, input.start)) {
started = true started = true
continue continue
} }
if (!started) continue if (!started) continue
if (event.type === "session.execution.started") {
if (child) {
await notifyChild(child, { type: "status", status: "running" })
}
continue
}
if (event.type === "session.step.started") { if (event.type === "session.step.started") {
assistantMessageID = event.data.assistantMessageID if (!child) assistantMessageID = event.data.assistantMessageID
continue continue
} }
if (event.type === "session.text.delta") { if (event.type === "session.text.delta") {
assistantMessageID = event.data.assistantMessageID if (!child) assistantMessageID = event.data.assistantMessageID
await update({ await send({
sessionUpdate: "agent_message_chunk", sessionUpdate: "agent_message_chunk",
messageId: event.data.assistantMessageID, messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta }, content: { type: "text", text: event.data.delta },
@@ -110,8 +197,8 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.reasoning.delta") { if (event.type === "session.reasoning.delta") {
assistantMessageID = event.data.assistantMessageID if (!child) assistantMessageID = event.data.assistantMessageID
await update({ await send({
sessionUpdate: "agent_thought_chunk", sessionUpdate: "agent_thought_chunk",
messageId: event.data.assistantMessageID, messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta }, content: { type: "text", text: event.data.delta },
@@ -119,9 +206,14 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.tool.input.started") { if (event.type === "session.tool.input.started") {
assistantMessageID = event.data.assistantMessageID if (!child) assistantMessageID = event.data.assistantMessageID
tools.set(event.data.id, { name: event.data.name, input: {}, metadata: {}, content: [] }) tools.set(toolKey(event.data.sessionID, event.data.id), {
await update({ name: event.data.name,
input: {},
metadata: {},
content: [],
})
await send({
sessionUpdate: "tool_call", sessionUpdate: "tool_call",
...pendingToolCall({ ...pendingToolCall({
toolCallId: event.data.id, toolCallId: event.data.id,
@@ -133,11 +225,12 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.tool.called") { if (event.type === "session.tool.called") {
assistantMessageID = event.data.assistantMessageID if (!child) assistantMessageID = event.data.assistantMessageID
const current = tools.get(event.data.id) ?? emptyToolState() const key = toolKey(event.data.sessionID, event.data.id)
const current = tools.get(key) ?? emptyToolState()
current.input = event.data.input current.input = event.data.input
tools.set(event.data.id, current) tools.set(key, current)
await update({ await send({
sessionUpdate: "tool_call_update", sessionUpdate: "tool_call_update",
...runningToolUpdate({ ...runningToolUpdate({
toolCallId: event.data.id, toolCallId: event.data.id,
@@ -149,10 +242,10 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.tool.progress") { if (event.type === "session.tool.progress") {
const current = tools.get(event.data.id) const current = tools.get(toolKey(event.data.sessionID, event.data.id))
if (!current) continue if (!current) continue
current.metadata = event.data.metadata current.metadata = event.data.metadata
await update({ await send({
sessionUpdate: "tool_call_update", sessionUpdate: "tool_call_update",
...runningToolUpdate({ ...runningToolUpdate({
toolCallId: event.data.id, toolCallId: event.data.id,
@@ -164,8 +257,9 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.tool.success") { if (event.type === "session.tool.success") {
const current = tools.get(event.data.id) ?? emptyToolState() const key = toolKey(event.data.sessionID, event.data.id)
tools.delete(event.data.id) const current = tools.get(key) ?? emptyToolState()
tools.delete(key)
await syncEditedFiles({ await syncEditedFiles({
connection: input.connection, connection: input.connection,
writeTextFile: input.writeTextFile, writeTextFile: input.writeTextFile,
@@ -175,7 +269,7 @@ export async function streamTurn(input: {
toolInput: current.input, toolInput: current.input,
metadata: event.data.metadata ?? {}, metadata: event.data.metadata ?? {},
}).catch(() => {}) }).catch(() => {})
await update({ await send({
sessionUpdate: "tool_call_update", sessionUpdate: "tool_call_update",
...completedToolUpdate({ ...completedToolUpdate({
toolCallId: event.data.id, toolCallId: event.data.id,
@@ -188,9 +282,10 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.tool.failed") { if (event.type === "session.tool.failed") {
const current = tools.get(event.data.id) ?? emptyToolState() const key = toolKey(event.data.sessionID, event.data.id)
tools.delete(event.data.id) const current = tools.get(key) ?? emptyToolState()
await update({ tools.delete(key)
await send({
sessionUpdate: "tool_call_update", sessionUpdate: "tool_call_update",
...errorToolUpdate({ ...errorToolUpdate({
toolCallId: event.data.id, toolCallId: event.data.id,
@@ -205,13 +300,33 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.step.ended") { if (event.type === "session.step.ended") {
assistantMessageID = event.data.assistantMessageID if (!child) {
finish = event.data.finish assistantMessageID = event.data.assistantMessageID
finish = event.data.finish
}
continue
}
if (event.type === "session.execution.succeeded") {
if (!child) return "succeeded" as const
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "completed" })
if (mode === "background" && openChildren.size === 0) return "succeeded" as const
continue
}
if (event.type === "session.execution.interrupted") {
if (!child) return "interrupted" as const
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "interrupted" })
if (mode === "background" && openChildren.size === 0) return "interrupted" as const
continue continue
} }
if (event.type === "session.execution.succeeded") return "succeeded" as const
if (event.type === "session.execution.interrupted") return "interrupted" as const
if (event.type === "session.execution.failed") { if (event.type === "session.execution.failed") {
if (child) {
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "failed", error: event.data.error })
if (mode === "background" && openChildren.size === 0) return "failed" as const
continue
}
executionError = event.data.error executionError = event.data.error
return "failed" as const return "failed" as const
} }
@@ -219,7 +334,13 @@ export async function streamTurn(input: {
return "interrupted" as const return "interrupted" as const
} }
const completed = consume() const completed = consume("turn")
const closeStream = async () => {
streamController.abort()
input.connectionSignal?.removeEventListener("abort", connectionAbort)
input.sessionSignal?.removeEventListener("abort", connectionAbort)
await stream.return?.(undefined).catch(() => {})
}
try { try {
await input.submit(control.admission.signal).catch((error) => { await input.submit(control.admission.signal).catch((error) => {
if (!control.cancelled) throw error if (!control.cancelled) throw error
@@ -233,6 +354,13 @@ export async function streamTurn(input: {
} }
} }
const terminal = await completed const terminal = await completed
if (input.childSessionUpdate && openChildren.size > 0 && !input.sessionSignal?.aborted) {
handedOff = true
input.sessionSignal?.addEventListener("abort", connectionAbort, { once: true })
void consume("background")
.catch(() => {})
.finally(closeStream)
}
const assistant = assistantMessageID const assistant = assistantMessageID
? await input.client.session ? await input.client.session
.message({ sessionID: input.sessionID, messageID: assistantMessageID }) .message({ sessionID: input.sessionID, messageID: assistantMessageID })
@@ -250,11 +378,38 @@ export async function streamTurn(input: {
await completed.catch(() => {}) await completed.catch(() => {})
throw error throw error
} finally { } finally {
streamController.abort() if (!handedOff) await closeStream()
await stream.return?.(undefined).catch(() => {})
} }
} }
function sessionIDFromEvent(event: EventSubscribeOutput) {
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
if (event.type === "form.created") return event.data.form.sessionID
return undefined
}
function toolKey(sessionID: string, id: string) {
return `${sessionID}:${id}`
}
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
const projected = { ...update }
projected._meta = {
...projected._meta,
"opencode/child-session": {
id: child.id,
parentID: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
},
}
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
projected.toolCallId = `${child.id}:${projected.toolCallId}`
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
}
return projected
}
export async function replayMessages( export async function replayMessages(
connection: Pick<AgentSideConnection, "sessionUpdate">, connection: Pick<AgentSideConnection, "sessionUpdate">,
sessionID: string, sessionID: string,
+17 -3
View File
@@ -20,20 +20,28 @@ export async function replyPermission(input: {
readonly connection: Connection readonly connection: Connection
readonly event: PermissionEvent readonly event: PermissionEvent
readonly sessionID: string readonly sessionID: string
readonly clientSessionID?: string
readonly cwd: string readonly cwd: string
readonly tool?: Tool readonly tool?: Tool
readonly toolCallPrefix?: string
readonly titlePrefix?: string
}) { }) {
const toolName = input.tool?.name ?? input.event.data.action const toolName = input.tool?.name ?? input.event.data.action
const toolInput = { ...input.event.data.metadata, ...input.tool?.input } const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
const previews = await permissionPreviews(toolName, toolInput, input.cwd) const previews = await permissionPreviews(toolName, toolInput, input.cwd)
const toolCallID = input.event.data.source?.id ?? input.event.data.id
const title = permissionTitle(toolName, toolInput, previews)
const result = await input.connection const result = await input.connection
.requestPermission({ .requestPermission({
sessionId: input.sessionID, sessionId: input.clientSessionID ?? input.sessionID,
toolCall: { toolCall: {
...pendingToolCall({ ...pendingToolCall({
toolCallId: input.event.data.source?.id ?? input.event.data.id, toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
toolName, toolName,
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) }, state: {
input: toolInput,
title: prefixedTitle(input.titlePrefix, title),
},
cwd: input.cwd, cwd: input.cwd,
}), }),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews), locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
@@ -51,6 +59,12 @@ export async function replyPermission(input: {
}) })
} }
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
if (!prefix) return title
if (!title) return prefix
return `${prefix}: ${title}`
}
export async function syncEditedFiles(input: { export async function syncEditedFiles(input: {
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">> readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
readonly writeTextFile: boolean readonly writeTextFile: boolean
+32 -7
View File
@@ -43,13 +43,21 @@ import { OPENCODE_VERSION } from "../version"
import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option" import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
import { promptContentToParts } from "./content" import { promptContentToParts } from "./content"
import { replayMessages, streamTurn, type TurnControl, type TurnStart } from "./event" import {
ChildSessionUpdateMethod,
ChildSessionUpdatesCapability,
replayMessages,
streamTurn,
type ChildSessionUpdate,
type TurnControl,
type TurnStart,
} from "./event"
import { ACPError } from "./error" import { ACPError } from "./error"
export const AuthMethodID = "opencode-login" export const AuthMethodID = "opencode-login"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> & type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">> Partial<Pick<AgentSideConnection, "writeTextFile" | "extNotification" | "signal">>
type Catalog = { type Catalog = {
readonly providers: ConfigOptionProvider[] readonly providers: ConfigOptionProvider[]
@@ -64,6 +72,7 @@ type Catalog = {
type Attached = { type Attached = {
readonly id: string readonly id: string
readonly cwd: string readonly cwd: string
readonly abort: AbortController
catalog: Catalog catalog: Catalog
model: ModelRef model: ModelRef
modeID: string modeID: string
@@ -100,7 +109,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const catalogs = new Map<string, Promise<Catalog>>() const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>() const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, TurnControl>() const active = new Map<string, TurnControl>()
const capabilities = { writeTextFile: false } const capabilities = { writeTextFile: false, childSessionUpdates: false }
const catalog = (cwd: string) => { const catalog = (cwd: string) => {
const cached = catalogs.get(cwd) const cached = catalogs.get(cwd)
@@ -119,11 +128,19 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
throw new ACPError.SessionNotFoundError({ sessionId: sessionID }) throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
} }
const detach = (sessionID: string) => {
sessions.get(sessionID)?.abort.abort()
sessions.delete(sessionID)
registeredMcp.delete(sessionID)
}
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => { const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
const currentCatalog = await catalog(cwd) const currentCatalog = await catalog(cwd)
sessions.get(session.id)?.abort.abort()
const state: Attached = { const state: Attached = {
id: session.id, id: session.id,
cwd, cwd,
abort: new AbortController(),
catalog: currentCatalog, catalog: currentCatalog,
model: session.model ?? currentCatalog.defaultModel, model: session.model ?? currentCatalog.defaultModel,
modeID: session.agent ?? currentCatalog.defaultModeID, modeID: session.agent ?? currentCatalog.defaultModeID,
@@ -161,6 +178,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return { return {
initialize: async (params) => { initialize: async (params) => {
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
capabilities.childSessionUpdates = params.clientCapabilities?._meta?.[ChildSessionUpdatesCapability] === true
const authMethod: AuthMethod = { const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal", description: "Run `opencode auth login` in the terminal",
name: "Login with opencode", name: "Login with opencode",
@@ -178,6 +196,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
mcpCapabilities: { http: true, sse: false }, mcpCapabilities: { http: true, sse: false },
promptCapabilities: { embeddedContext: true, image: true }, promptCapabilities: { embeddedContext: true, image: true },
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} }, sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
_meta: { [ChildSessionUpdatesCapability]: true },
}, },
authMethods: [authMethod], authMethods: [authMethod],
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION }, agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
@@ -224,8 +243,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => { await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
if (!isSessionNotFoundError(error)) throw error if (!isSessionNotFoundError(error)) throw error
}) })
sessions.delete(params.sessionId) detach(params.sessionId)
registeredMcp.delete(params.sessionId)
return {} return {}
}, },
resumeSession: async (params) => { resumeSession: async (params) => {
@@ -234,8 +252,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return { configOptions: configOptions(state) } return { configOptions: configOptions(state) }
}, },
closeSession: async (params) => { closeSession: async (params) => {
sessions.delete(params.sessionId) detach(params.sessionId)
registeredMcp.delete(params.sessionId)
const turn = active.get(params.sessionId) const turn = active.get(params.sessionId)
if (turn) { if (turn) {
turn.cancelled = true turn.cancelled = true
@@ -296,6 +313,11 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const messageID = SessionMessage.ID.create() const messageID = SessionMessage.ID.create()
const prepared = preparePrompt(state.catalog, params.prompt, messageID) const prepared = preparePrompt(state.catalog, params.prompt, messageID)
const control: TurnControl = { cancelled: false, admission: new AbortController() } const control: TurnControl = { cancelled: false, admission: new AbortController() }
const extNotification = input.connection.extNotification
const childSessionUpdate =
capabilities.childSessionUpdates && extNotification
? (update: ChildSessionUpdate) => extNotification(ChildSessionUpdateMethod, update).then(() => {})
: undefined
active.set(state.id, control) active.set(state.id, control)
const response = await streamTurn({ const response = await streamTurn({
client: input.client, client: input.client,
@@ -305,7 +327,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
start: prepared.start, start: prepared.start,
writeTextFile: capabilities.writeTextFile, writeTextFile: capabilities.writeTextFile,
control, control,
connectionSignal: input.connection.signal,
sessionSignal: state.abort.signal,
submit: (signal) => submitPrompt(input.client, state, prepared, signal), submit: (signal) => submitPrompt(input.client, state, prepared, signal),
...(childSessionUpdate ? { childSessionUpdate } : {}),
}).finally(() => { }).finally(() => {
if (active.get(state.id) === control) active.delete(state.id) if (active.get(state.id) === control) active.delete(state.id)
}) })
+3 -6
View File
@@ -8,7 +8,7 @@ import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process" import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto" import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path" 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 { HttpServer } from "effect/unstable/http"
import { Env } from "./env" import { Env } from "./env"
import { ServiceConfig } from "./services/service-config" import { ServiceConfig } from "./services/service-config"
@@ -81,7 +81,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
database: { database: {
path: path:
process.env.OPENCODE_DB ?? 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 === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true" process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db" ? "opencode.db"
@@ -108,9 +108,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
gitbash: process.env.OPENCODE_GIT_BASH_PATH, gitbash: process.env.OPENCODE_GIT_BASH_PATH,
}, },
fs: { fs: {
filewatcher: !truthy( filewatcher: !truthy(process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER),
process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER,
),
fff: fff:
process.env.OPENCODE_DISABLE_FFF === undefined process.env.OPENCODE_DISABLE_FFF === undefined
? process.platform !== "win32" ? process.platform !== "win32"
@@ -128,7 +126,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
}), }),
}, },
).pipe( ).pipe(
Effect.provide(Logger.layer([], { mergeWithExisting: false })),
Effect.catch((error) => { Effect.catch((error) => {
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error) if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
return recognizeIncumbent(serviceOptions, hostname, port).pipe( return recognizeIncumbent(serviceOptions, hostname, port).pipe(
+189 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk" import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { resolve } from "node:path" import { resolve } from "node:path"
import { replayMessages, streamTurn, type TurnControl } from "../../src/acp/event" import { replayMessages, streamTurn, type ChildSessionUpdate, type TurnControl } from "../../src/acp/event"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture" import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0] type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
@@ -191,6 +191,181 @@ describe("acp event behavior", () => {
} }
}) })
test("projects foreground child session updates onto the parent turn", async () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_child",
...childSession("ses_child", "ses_parent", "Explore code"),
}),
)
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
name: "read",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
input: { path: "/workspace/src/index.ts" },
executed: false,
}),
)
send(
durableEvent("session.tool.success", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
metadata: {},
content: [{ type: "text", text: "source" }],
executed: true,
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection(updates),
sessionID: "ses_parent",
inputID: "input_parent",
})
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
["ses_parent", "tool_call"],
["ses_parent", "tool_call_update"],
["ses_parent", "tool_call_update"],
])
expect(updates.map((item) => ("toolCallId" in item.update ? item.update.toolCallId : undefined))).toEqual([
"ses_child:call_read",
"ses_child:call_read",
"ses_child:call_read",
])
expect(updates[0]?.update).toMatchObject({
title: "Explore code: read",
_meta: {
"opencode/child-session": {
id: "ses_child",
parentID: "ses_parent",
depth: 1,
title: "Explore code",
},
},
})
expect(response.stopReason).toBe("end_turn")
} finally {
await fixture.stop()
}
})
test("continues child extension updates after the parent turn ends", async () => {
const updates: SessionUpdateParams[] = []
const childUpdates: ChildSessionUpdate[] = []
const completed = Promise.withResolvers<void>()
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_background",
...childSession("ses_background", "ses_parent", "Background research"),
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection(updates),
sessionID: "ses_parent",
inputID: "input_parent",
childSessionUpdate: async (update) => {
childUpdates.push(update)
if (update.type === "status" && update.status === "completed") completed.resolve()
},
})
expect(response.stopReason).toBe("end_turn")
fixture.send(
durableEvent("session.created", {
sessionID: "ses_future",
...childSession("ses_future", "ses_parent", "Later turn child"),
}),
)
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" }))
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_background" }))
fixture.send(
durableEvent("session.tool.input.started", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
name: "shell",
}),
)
fixture.send(
durableEvent("session.tool.called", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
input: { command: "pwd" },
executed: false,
}),
)
fixture.send(
durableEvent("session.tool.success", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
metadata: { exit: 0 },
content: [{ type: "text", text: "/workspace" }],
executed: true,
}),
)
fixture.send(durableEvent("session.execution.succeeded", { sessionID: "ses_background" }))
await withTimeout(completed.promise, "background child completion was not delivered")
expect(updates).toEqual([])
expect(
childUpdates.map((update) =>
update.type === "status" ? [update.type, update.status] : [update.type, update.update.sessionUpdate],
),
).toEqual([
["status", "created"],
["status", "running"],
["update", "tool_call"],
["update", "tool_call_update"],
["update", "tool_call_update"],
["status", "completed"],
])
expect(childUpdates[2]).toMatchObject({
rootSessionId: "ses_parent",
childSessionId: "ses_background",
parentSessionId: "ses_parent",
depth: 1,
title: "Background research",
type: "update",
update: { toolCallId: "ses_background:call_shell" },
})
expect(childUpdates.some((update) => update.childSessionId === "ses_future")).toBe(false)
} finally {
await fixture.stop()
}
})
test("streams tool pending, progress, success, and failure updates", async () => { test("streams tool pending, progress, success, and failure updates", async () => {
const updates: SessionUpdateParams[] = [] const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({ const fixture = createSseFixture({
@@ -556,6 +731,7 @@ function turn(input: {
readonly connection: Connection readonly connection: Connection
readonly sessionID: string readonly sessionID: string
readonly inputID: string readonly inputID: string
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
}) { }) {
return streamTurn({ return streamTurn({
client: input.fixture.client, client: input.fixture.client,
@@ -565,11 +741,23 @@ function turn(input: {
start: { type: "input", id: input.inputID }, start: { type: "input", id: input.inputID },
writeTextFile: false, writeTextFile: false,
control: { cancelled: false, admission: new AbortController() }, control: { cancelled: false, admission: new AbortController() },
childSessionUpdate: input.childSessionUpdate,
submit: (signal) => submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }), input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
}) })
} }
function childSession(id: string, parentID: string, title: string) {
return {
slug: id,
projectID: "project",
location: { directory: "/workspace" },
parentID,
title,
version: "test",
}
}
function tokens() { function tokens() {
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
} }
@@ -153,6 +153,64 @@ describe("acp permission behavior", () => {
} }
}) })
test("routes foreground child permissions through the parent ACP session", async () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_child",
slug: "ses_child",
projectID: "project",
location: { directory: "/workspace" },
parentID: "ses_parent",
title: "Review code",
version: "test",
}),
)
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
send(
permissionAsked("ses_child", "perm_child", {
action: "read",
metadata: { path: "/workspace/child.ts" },
source: { type: "tool", messageID: "msg_child", id: "call_child" },
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return { outcome: { outcome: "selected", optionId: "once" } } as const
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_parent", "input_parent")
expect(permissionRequests).toHaveLength(1)
expect(permissionRequests[0]).toMatchObject({
sessionId: "ses_parent",
toolCall: {
toolCallId: "ses_child:call_child",
title: "Review code: /workspace/child.ts",
},
})
expect(fixture.requests).toContainEqual(
expect.objectContaining({
method: "POST",
path: "/api/session/ses_child/permission/perm_child/reply",
}),
)
} finally {
await fixture.stop()
}
})
test("previews edits during approval and syncs the completed file", async () => { test("previews edits during approval and syncs the completed file", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-")) const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
const file = path.join(cwd, "file.ts") const file = path.join(cwd, "file.ts")
+7
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk" import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode-ai/client/promise" import { OpenCode } from "@opencode-ai/client/promise"
import { ACPService } from "../../src/acp/service" import { ACPService } from "../../src/acp/service"
import { ChildSessionUpdatesCapability } from "../../src/acp/event"
describe("acp service", () => { describe("acp service", () => {
test("creates a v2 session, registers mcp, and publishes commands", async () => { test("creates a v2 session, registers mcp, and publishes commands", async () => {
@@ -39,11 +40,17 @@ describe("acp service", () => {
}) })
try { try {
const initialized = await service.initialize({
protocolVersion: 1,
clientCapabilities: { _meta: { [ChildSessionUpdatesCapability]: true } },
clientInfo: { name: "test", version: "1" },
})
const result = await service.newSession({ const result = await service.newSession({
cwd: "/workspace", cwd: "/workspace",
mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }], mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }],
}) })
expect(result.sessionId).toBe("ses_acp") expect(result.sessionId).toBe("ses_acp")
expect(initialized.agentCapabilities?._meta).toEqual({ [ChildSessionUpdatesCapability]: true })
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"]) expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(requests).toContainEqual({ expect(requests).toContainEqual({
method: "PUT", method: "PUT",
+45 -6
View File
@@ -283,6 +283,26 @@ export type Endpoint5_26Input = {
} }
export type Endpoint5_26Output = 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 id: Event.ID
readonly created: DateTime.Utc readonly created: DateTime.Utc
@@ -420,6 +440,7 @@ export type Endpoint5_26Output =
readonly data: { readonly data: {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly delta: { readonly [x: string]: (string & Brand.Brand<"Instruction.Hash">) | "removed" } readonly delta: { readonly [x: string]: (string & Brand.Brand<"Instruction.Hash">) | "removed" }
readonly text?: string | undefined
} }
} }
| { | {
@@ -1538,19 +1559,36 @@ export interface DebugApi<E = never> {
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> } readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
} }
export type Endpoint27_0Input = { export type Endpoint27_0Output =
| { readonly status: "required" | "completed" }
| {
readonly status: "running"
readonly progress: {
readonly label: string
readonly numerator?: number | undefined
readonly denominator?: number | undefined
}
}
| { readonly status: "error"; readonly error: string }
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
export interface MigrationApi<E = never> {
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
}
export type Endpoint28_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined 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 Endpoint28_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 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 location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string readonly query: string
readonly providerID?: WebSearch.ID | undefined readonly providerID?: WebSearch.ID | undefined
} }
export type Endpoint27_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response } export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E> export type WebsearchQueryOperation<E = never> = (input: Endpoint28_1Input) => Effect.Effect<Endpoint28_1Output, E>
export interface WebsearchApi<E = never> { export interface WebsearchApi<E = never> {
readonly providers: WebsearchProvidersOperation<E> readonly providers: WebsearchProvidersOperation<E>
@@ -1585,5 +1623,6 @@ export interface AppApi<E = never> {
readonly projectCopy: ProjectCopyApi<E> readonly projectCopy: ProjectCopyApi<E>
readonly vcs: VcsApi<E> readonly vcs: VcsApi<E>
readonly debug: DebugApi<E> readonly debug: DebugApi<E>
readonly migration: MigrationApi<E>
readonly websearch: WebsearchApi<E> readonly websearch: WebsearchApi<E>
} }
+18 -11
View File
@@ -215,10 +215,11 @@ import type {
Endpoint26_0Output, Endpoint26_0Output,
Endpoint26_1Input, Endpoint26_1Input,
Endpoint26_1Output, Endpoint26_1Output,
Endpoint27_0Input,
Endpoint27_0Output, Endpoint27_0Output,
Endpoint27_1Input, Endpoint28_0Input,
Endpoint27_1Output, Endpoint28_0Output,
Endpoint28_1Input,
Endpoint28_1Output,
} from "../api/api.js" } from "../api/api.js"
import { ClientError } from "./client-error" import { ClientError } from "./client-error"
@@ -1217,22 +1218,27 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) }, location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
}) })
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) => const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
preserveEffect<Endpoint27_0Output>()( preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } })
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
preserveEffect<Endpoint28_0Output>()(
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) => const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
preserveEffect<Endpoint27_1Output>()( preserveEffect<Endpoint28_1Output>()(
raw["websearch.query"]({ raw["websearch.query"]({
query: { location: input["location"] }, query: { location: input["location"] },
payload: { query: input["query"], providerID: input["providerID"] }, payload: { query: input["query"], providerID: input["providerID"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({ const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
providers: Endpoint27_0(raw), providers: Endpoint28_0(raw),
query: Endpoint27_1(raw), query: Endpoint28_1(raw),
}) })
const adaptClient = (raw: RawClient) => ({ const adaptClient = (raw: RawClient) => ({
@@ -1263,7 +1269,8 @@ const adaptClient = (raw: RawClient) => ({
projectCopy: adaptGroup24(raw["server.projectCopy"]), projectCopy: adaptGroup24(raw["server.projectCopy"]),
vcs: adaptGroup25(raw["server.vcs"]), vcs: adaptGroup25(raw["server.vcs"]),
debug: adaptGroup26(raw["server.debug"]), 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 }) => export const make = (options?: { readonly baseUrl?: URL | string }) =>
+10 -11
View File
@@ -48,11 +48,11 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
}) })
// Idempotent ensure-running: reuses a healthy compatible server, replaces a // Idempotent ensure-running: reuses a healthy compatible server, replaces a
// version-mismatched one, and otherwise spawns small contenders until a server // version-mismatched one, and otherwise spawns one contender until a server
// becomes discoverable. A contender is never killed merely for slow startup. // becomes discoverable. The contender is never killed merely for slow startup.
/** Ensure a healthy, compatible local service is running. */ /** Ensure a healthy, compatible local service is running. */
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) { export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
const contenders = new Set<Contender>() let contender: Contender | undefined
let announced = false let announced = false
let lastSpawn = 0 let lastSpawn = 0
let spawnDelay = 5_000 let spawnDelay = 5_000
@@ -95,17 +95,16 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
return Option.none<LocalService>() return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now() } else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
const finished = [...contenders].filter(contenderFinished) const finished = contender !== undefined && contenderFinished(contender) ? contender : undefined
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined) const failure = finished === undefined ? undefined : contenderFailure(finished)
if (finished.some((item) => item.child.exitCode === 0)) { if (finished?.child.exitCode === 0) {
spawnDelay = Math.min(spawnDelay * 2, 30_000) spawnDelay = Math.min(spawnDelay * 2, 30_000)
} }
finished.forEach((item) => contenders.delete(item)) if (finished !== undefined) contender = undefined
if (failure !== undefined && contenders.size === 0) return yield* Effect.fail(failure) if (failure !== undefined) return yield* Effect.fail(failure)
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery. if (contender === undefined && Date.now() - lastSpawn >= spawnDelay) {
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
yield* announce("missing") yield* announce("missing")
contenders.add(yield* spawnContender) contender = yield* spawnContender
lastSpawn = Date.now() lastSpawn = Date.now()
} }
return Option.none<LocalService>() return Option.none<LocalService>()
@@ -211,6 +211,7 @@ import type {
DebugLocationListOutput, DebugLocationListOutput,
DebugLocationEvictInput, DebugLocationEvictInput,
DebugLocationEvictOutput, DebugLocationEvictOutput,
MigrationV1StatusOutput,
WebsearchProvidersInput, WebsearchProvidersInput,
WebsearchProvidersOutput, WebsearchProvidersOutput,
WebsearchQueryInput, WebsearchQueryInput,
@@ -492,7 +493,7 @@ export function make(options: ClientOptions) {
method: "GET", method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}`, path: `/api/session/${encodeURIComponent(input.sessionID)}`,
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 401, 400],
empty: false, empty: false,
}, },
requestOptions, requestOptions,
@@ -718,7 +719,7 @@ export function make(options: ClientOptions) {
method: "GET", method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context`, path: `/api/session/${encodeURIComponent(input.sessionID)}/context`,
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 500, 400, 401], declaredStatuses: [404, 500, 401, 400],
empty: false, empty: false,
}, },
requestOptions, requestOptions,
@@ -730,7 +731,7 @@ export function make(options: ClientOptions) {
method: "GET", method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending`, path: `/api/session/${encodeURIComponent(input.sessionID)}/pending`,
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 401, 400],
empty: false, empty: false,
}, },
requestOptions, requestOptions,
@@ -793,7 +794,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/log`, path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/log`,
query: { after: input["after"], follow: input["follow"] }, query: { after: input["after"], follow: input["follow"] },
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 401, 400],
empty: false, empty: false,
}, },
requestOptions, requestOptions,
@@ -826,7 +827,7 @@ export function make(options: ClientOptions) {
method: "GET", method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`, path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 401, 400],
empty: false, empty: false,
}, },
requestOptions, requestOptions,
@@ -1768,6 +1769,21 @@ 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,
),
},
},
websearch: { websearch: {
providers: (input?: WebsearchProvidersInput, requestOptions?: RequestOptions) => providers: (input?: WebsearchProvidersInput, requestOptions?: RequestOptions) =>
request<WebsearchProvidersOutput>( request<WebsearchProvidersOutput>(
+30 -421
View File
@@ -313,164 +313,6 @@ export type SkillInfo = {
content: string 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 PermissionReply = "once" | "always" | "reject"
export type Pty = { export type Pty = {
@@ -569,6 +411,27 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect } 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 = { export type SessionAgentSelected = {
id: string id: string
created: number created: number
@@ -676,7 +539,7 @@ export type SessionInstructionsUpdated = {
type: "session.instructions.updated" type: "session.instructions.updated"
durable: { aggregateID: string; seq: number; version: 2 } durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef location?: LocationRef
data: { sessionID: string; delta: { [x: string]: string | "removed" } } data: { sessionID: string; delta: { [x: string]: string | "removed" }; text?: string }
} }
export type SessionSynthetic = { export type SessionSynthetic = {
@@ -862,26 +725,6 @@ export type AgentUpdated = {
data: {} 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 = { export type SessionUsageUpdated = {
id: string id: string
created: number 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 = { export type PermissionReplied = {
id: string id: string
created: number created: number
@@ -1904,23 +1657,6 @@ export type FormReplied = {
data: { id: string; sessionID: string; answer: FormAnswer } 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 = { export type QuestionAsked = {
id: string id: string
created: number created: number
@@ -1998,41 +1734,6 @@ export type IntegrationMethod =
export type FormFields = [FormField, ...Array<FormField>] 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 FormFields1 = [FormField1, ...Array<FormField1>]
export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction 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 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 FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
export type SessionInputAdmitted = { export type SessionInputAdmitted = {
@@ -2137,12 +1788,6 @@ export type SessionMessageInfo =
| SessionMessageAssistant | SessionMessageAssistant
| SessionMessageCompaction | SessionMessageCompaction
export type SessionV1ToolState =
| SessionV1ToolStatePending
| SessionV1ToolStateRunning
| SessionV1ToolStateCompleted
| SessionV1ToolStateError
export type FormCreated = { export type FormCreated = {
id: string id: string
created: number created: number
@@ -2153,6 +1798,7 @@ export type FormCreated = {
} }
export type SessionEventDurable = export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected | SessionAgentSelected
| SessionModelSelected | SessionModelSelected
| SessionMoved | SessionMoved
@@ -2197,43 +1843,6 @@ export type SessionMessagesResponse = {
cursor: { previous?: string | null; next?: string | null } 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 = export type V2Event =
| ModelsDevRefreshed | ModelsDevRefreshed
| IntegrationUpdated | IntegrationUpdated
@@ -2241,12 +1850,6 @@ export type V2Event =
| CatalogUpdated | CatalogUpdated
| AgentUpdated | AgentUpdated
| SessionCreated | SessionCreated
| SessionUpdated
| SessionDeleted1
| MessageUpdated
| MessageRemoved
| MessagePartUpdated
| MessagePartRemoved
| SessionAgentSelected | SessionAgentSelected
| SessionModelSelected | SessionModelSelected
| SessionMoved | SessionMoved
@@ -2325,9 +1928,10 @@ export type V2Event =
| VcsBranchUpdated | VcsBranchUpdated
| McpStatusChanged | McpStatusChanged
| McpResourcesChanged | McpResourcesChanged
| SessionError
| V2EventServerConnected | V2EventServerConnected
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string } export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError => export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError" typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
@@ -4961,6 +4565,11 @@ export type DebugLocationEvictInput = {
export type DebugLocationEvictOutput = void export type DebugLocationEvictOutput = void
export type MigrationV1StatusOutput =
| { status: "required" | "completed" }
| { status: "running"; progress: { label: string; numerator?: number | undefined; denominator?: number | undefined } }
| { status: "error"; error: string }
export type WebsearchProvidersInput = { export type WebsearchProvidersInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+8 -9
View File
@@ -33,7 +33,7 @@ async function discoverLocal(options: DiscoverOptions) {
/** Ensure a healthy, compatible local service is running. */ /** Ensure a healthy, compatible local service is running. */
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> { export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const deadline = Date.now() + 120_000 const deadline = Date.now() + 120_000
const contenders = new Set<Contender>() let contender: Contender | undefined
let announced = false let announced = false
let lastSpawn = 0 let lastSpawn = 0
let spawnDelay = 5_000 let spawnDelay = 5_000
@@ -76,17 +76,16 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
} }
} else { } else {
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now() if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
const finished = [...contenders].filter(contenderFinished) const finished = contender !== undefined && contenderFinished(contender) ? contender : undefined
const failure = finished.map(contenderFailure).find((error) => error !== undefined) const failure = finished === undefined ? undefined : contenderFailure(finished)
if (finished.some((item) => item.child.exitCode === 0)) { if (finished?.child.exitCode === 0) {
spawnDelay = Math.min(spawnDelay * 2, 30_000) spawnDelay = Math.min(spawnDelay * 2, 30_000)
} }
finished.forEach((item) => contenders.delete(item)) if (finished !== undefined) contender = undefined
if (failure !== undefined && contenders.size === 0) throw failure if (failure !== undefined) throw failure
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery. if (contender === undefined && Date.now() - lastSpawn >= spawnDelay) {
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
announce("missing") announce("missing")
contenders.add(spawnContender()) contender = spawnContender()
lastSpawn = Date.now() lastSpawn = Date.now()
} }
} }
+3 -11
View File
@@ -9,21 +9,13 @@ if (mode === "record-start") {
} }
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL") if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
if ( if (mode === "delayed" || mode === "delayed-failed") {
mode === "delayed" ||
mode === "delayed-failed" ||
mode === "coordinated" ||
mode === "coordinated-failed-loser"
) {
await appendFile(registration + ".starts", process.pid + "\n") await appendFile(registration + ".starts", process.pid + "\n")
const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" }) const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" })
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
if (!owner) process.exit(mode === "coordinated-failed-loser" ? 1 : 0) if (!owner) process.exit(0)
if (mode === "coordinated" || mode === "coordinated-failed-loser") { await Bun.sleep(Number(delay))
while ((await Bun.file(registration + ".starts").text()).trim().split("\n").length < 2) await Bun.sleep(10)
if (mode === "coordinated-failed-loser") await Bun.sleep(1_500)
} else await Bun.sleep(Number(delay))
if (mode === "delayed-failed") process.exit(1) if (mode === "delayed-failed") process.exit(1)
} }
+1 -19
View File
@@ -31,7 +31,7 @@ test("ensures a missing service with native promises", async () => {
const endpoint = await Service.ensure({ const endpoint = await Service.ensure({
file: registration, file: registration,
version: "test", version: "test",
command: [process.execPath, fixture, registration, "coordinated"], command: [process.execPath, fixture, registration, "delayed", "100"],
onStart: (reason) => starts.push(reason), onStart: (reason) => starts.push(reason),
}) })
const info = await Bun.file(registration).json() const info = await Bun.file(registration).json()
@@ -44,24 +44,6 @@ test("ensures a missing service with native promises", async () => {
} }
}, 15_000) }, 15_000)
test("waits for a live contender when another native contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
})
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
}, 15_000)
test("reports a failed registered service", async () => { test("reports a failed registered service", async () => {
const registration = await setup("failed-owner") const registration = await setup("failed-owner")
+3 -21
View File
@@ -121,39 +121,21 @@ test("a legacy health response is still replaced", async () => {
await existing.exited await existing.exited
}, 10_000) }, 10_000)
test("waits for a slow winner while bounding lock probes", async () => { test("does not spawn another contender while the first is starting", async () => {
const directory = await temp() const directory = await temp()
const registration = join(directory, "service.json") const registration = join(directory, "service.json")
const endpoint = await run( const endpoint = await run(
Service.ensure({ Service.ensure({
file: registration, file: registration,
version: "test", version: "test",
command: [process.execPath, fixture, registration, "coordinated"], command: [process.execPath, fixture, registration, "delayed", "6000"],
}), }),
) )
const info = await Bun.file(registration).json() const info = await Bun.file(registration).json()
try { try {
expect(endpoint.url).toBe(info.url) expect(endpoint.url).toBe(info.url)
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: info.pid }) expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: info.pid })
expect((await Bun.file(registration + ".starts").text()).trim().split("\n")).toHaveLength(2) expect((await Bun.file(registration + ".starts").text()).trim().split("\n")).toHaveLength(1)
} finally {
process.kill(info.pid, "SIGTERM")
}
}, 15_000)
test("waits for a live contender when another contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await run(
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
}),
)
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
} finally { } finally {
process.kill(info.pid, "SIGTERM") process.kill(info.pid, "SIGTERM")
} }
@@ -58,13 +58,3 @@ export const setMethods = new Set([
"isSupersetOf", "isSupersetOf",
"isDisjointFrom", "isDisjointFrom",
]) ])
export const spreadItems = (value: unknown): Array<unknown> | undefined => {
if (Array.isArray(value)) return value
if (typeof value === "string") return Array.from(value)
if (value instanceof CodeModeMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
if (value instanceof CodeModeSet) return Array.from(value.set.values())
if (value instanceof CodeModeURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
return undefined
}
import { CodeModeMap, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

@@ -1,103 +0,0 @@
[data-component="desktop-promo"] {
--promo-background: hsl(0, 20%, 99%);
--promo-background-weak: hsl(0, 8%, 97%);
--promo-text: hsl(0, 1%, 39%);
--promo-text-strong: hsl(0, 5%, 12%);
--promo-border: hsla(0, 100%, 3%, 0.12);
position: fixed;
z-index: 20;
right: 1.5rem;
bottom: 1.5rem;
width: min(28rem, calc(100vw - 2rem));
padding: 4px;
overflow: hidden;
color: var(--promo-text);
border: 1px solid var(--promo-border);
border-radius: 8px;
background: var(--promo-background);
box-shadow: 0 0.75rem 2rem rgb(0 0 0 / 15%);
font-family: var(--font-mono);
@media (prefers-color-scheme: dark) {
--promo-background: hsl(0, 9%, 7%);
--promo-background-weak: hsl(0, 6%, 10%);
--promo-text: hsl(0, 4%, 71%);
--promo-text-strong: hsl(0, 15%, 94%);
--promo-border: hsl(0, 4%, 23%);
}
@media (max-width: 40rem) {
right: 1rem;
bottom: 1rem;
}
[data-slot="desktop-promo-link"] {
display: block;
color: var(--promo-text);
text-decoration: none;
}
[data-slot="desktop-promo-link"]:focus-visible {
outline: 2px solid var(--promo-text-strong);
outline-offset: -3px;
}
video {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 4px;
background: var(--promo-background-weak);
}
[data-slot="desktop-promo-copy"] {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 1rem;
font-size: 0.875rem;
line-height: 1.4;
}
[data-slot="desktop-promo-copy"] strong {
color: var(--promo-text-strong);
font-weight: 500;
}
[data-slot="desktop-promo-close"] {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: grid;
width: 2rem;
height: 2rem;
padding: 0;
place-items: center;
cursor: pointer;
color: white;
border: none;
border-radius: 0.25rem;
background: rgb(0 0 0 / 70%);
opacity: 0;
transition:
opacity 150ms ease,
background 150ms ease;
}
&:hover [data-slot="desktop-promo-close"],
[data-slot="desktop-promo-close"]:focus-visible {
opacity: 1;
}
[data-slot="desktop-promo-close"]:hover {
background: rgb(0 0 0 / 90%);
}
@media (hover: none) {
[data-slot="desktop-promo-close"] {
opacity: 1;
}
}
}
@@ -1,60 +0,0 @@
import "./desktop-promo.css"
import { A, useLocation } from "@solidjs/router"
import { createSignal, Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import desktopPromoVideo from "~/asset/lander/desktop-tabs-landscape.mp4"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { strip } from "~/lib/language"
const DISMISSED_COOKIE = "desktop_promo_dismissed"
export function DesktopPromo() {
const i18n = useI18n()
const language = useLanguage()
const location = useLocation()
const request = getRequestEvent()?.request
const cookie = request?.headers.get("cookie") ?? (typeof document === "object" ? document.cookie : "")
const [visible, setVisible] = createSignal(
!cookie.split(";").some((value) => value.trim() === `${DISMISSED_COOKIE}=1`),
)
const hostname = request ? new URL(request.url).hostname : typeof window === "object" ? window.location.hostname : ""
const primaryHost =
hostname === "opencode.ai" || hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"
return (
<Show
when={
visible() &&
primaryHost &&
strip(location.pathname) !== "/download" &&
!strip(location.pathname).startsWith("/download/")
}
>
<aside data-component="desktop-promo">
<A href={language.route("/download")} data-slot="desktop-promo-link">
<video src={desktopPromoVideo} autoplay playsinline loop muted preload="metadata" aria-hidden="true" />
<span data-slot="desktop-promo-copy">
<strong>{i18n.t("home.promo.title")}</strong>
<span>
{i18n.t("home.promo.body")} {i18n.t("home.promo.cta")}
</span>
</span>
</A>
<button
type="button"
data-slot="desktop-promo-close"
onClick={() => {
document.cookie = `${DISMISSED_COOKIE}=1; Path=/; Max-Age=31536000; SameSite=Lax`
setVisible(false)
}}
>
<span class="sr-only">{i18n.t("home.promo.close")}</span>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 5L15 15M15 5L5 15" stroke="currentColor" stroke-width="1.5" />
</svg>
</button>
</aside>
</Show>
)
}
@@ -19,10 +19,6 @@ export function Span({ children, ...props }: SpanProps) {
return React.createElement("span", props, children) return React.createElement("span", props, children)
} }
export function Wbr({ children, ...props }: WbrProps) {
return React.createElement("wbr", props, children)
}
export function Fonts({ assetsUrl }: { assetsUrl: string }) { export function Fonts({ assetsUrl }: { assetsUrl: string }) {
return ( return (
<> <>
@@ -59,14 +55,3 @@ export function Fonts({ assetsUrl }: { assetsUrl: string }) {
</> </>
) )
} }
export function SplitString({ text, split }: { text: string; split: number }) {
const segments: JSX.Element[] = []
for (let i = 0; i < text.length; i += split) {
segments.push(<>{text.slice(i, i + split)}</>)
if (i + split < text.length) {
segments.push(<Wbr key={`${i}wbr`} />)
}
}
return <>{segments}</>
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

+1
View File
@@ -9,6 +9,7 @@
"db": "bun drizzle-kit", "db": "bun drizzle-kit",
"migration": "bun run script/migration.ts", "migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts", "fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"test": "bun test --only-failures", "test": "bun test --only-failures",
"typecheck": "tsgo --noEmit" "typecheck": "tsgo --noEmit"
}, },
+58 -399
View File
@@ -1,19 +1,15 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1", "id": "2d214a71-3b0a-48c1-a667-741952c4e188",
"prevIds": [ "prevIds": [
"db37a97f-9b5e-4c87-be8b-4feace35136c" "f14a9b18-8207-487e-a3d3-227e629ba9ad"
], ],
"ddl": [ "ddl": [
{ {
"name": "workspace", "name": "workspace",
"entityType": "tables" "entityType": "tables"
}, },
{
"name": "data_migration",
"entityType": "tables"
},
{ {
"name": "account_state", "name": "account_state",
"entityType": "tables" "entityType": "tables"
@@ -66,14 +62,6 @@
"name": "instruction_state", "name": "instruction_state",
"entityType": "tables" "entityType": "tables"
}, },
{
"name": "message",
"entityType": "tables"
},
{
"name": "part",
"entityType": "tables"
},
{ {
"name": "session_message", "name": "session_message",
"entityType": "tables" "entityType": "tables"
@@ -83,11 +71,7 @@
"entityType": "tables" "entityType": "tables"
}, },
{ {
"name": "session", "name": "session_v2",
"entityType": "tables"
},
{
"name": "session_share",
"entityType": "tables" "entityType": "tables"
}, },
{ {
@@ -170,26 +154,6 @@
"entityType": "columns", "entityType": "columns",
"table": "workspace" "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", "type": "integer",
"notNull": false, "notNull": false,
@@ -534,7 +498,7 @@
"type": "integer", "type": "integer",
"notNull": true, "notNull": true,
"autoincrement": false, "autoincrement": false,
"default": null, "default": "0",
"generated": null, "generated": null,
"name": "created", "name": "created",
"entityType": "columns", "entityType": "columns",
@@ -960,116 +924,6 @@
"entityType": "columns", "entityType": "columns",
"table": "instruction_state" "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", "type": "text",
"notNull": false, "notNull": false,
@@ -1218,7 +1072,7 @@
"generated": null, "generated": null,
"name": "id", "name": "id",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1228,7 +1082,7 @@
"generated": null, "generated": null,
"name": "project_id", "name": "project_id",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1238,7 +1092,7 @@
"generated": null, "generated": null,
"name": "workspace_id", "name": "workspace_id",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1248,7 +1102,7 @@
"generated": null, "generated": null,
"name": "parent_id", "name": "parent_id",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1258,7 +1112,7 @@
"generated": null, "generated": null,
"name": "fork_session_id", "name": "fork_session_id",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1268,7 +1122,7 @@
"generated": null, "generated": null,
"name": "fork_boundary", "name": "fork_boundary",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1278,7 +1132,7 @@
"generated": null, "generated": null,
"name": "slug", "name": "slug",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1288,7 +1142,7 @@
"generated": null, "generated": null,
"name": "directory", "name": "directory",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1298,7 +1152,7 @@
"generated": null, "generated": null,
"name": "path", "name": "path",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1308,7 +1162,7 @@
"generated": null, "generated": null,
"name": "title", "name": "title",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1318,7 +1172,7 @@
"generated": null, "generated": null,
"name": "version", "name": "version",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1328,7 +1182,7 @@
"generated": null, "generated": null,
"name": "share_url", "name": "share_url",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "integer", "type": "integer",
@@ -1338,7 +1192,7 @@
"generated": null, "generated": null,
"name": "summary_additions", "name": "summary_additions",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "integer", "type": "integer",
@@ -1348,7 +1202,7 @@
"generated": null, "generated": null,
"name": "summary_deletions", "name": "summary_deletions",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "integer", "type": "integer",
@@ -1358,7 +1212,7 @@
"generated": null, "generated": null,
"name": "summary_files", "name": "summary_files",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1368,7 +1222,7 @@
"generated": null, "generated": null,
"name": "summary_diffs", "name": "summary_diffs",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1378,7 +1232,7 @@
"generated": null, "generated": null,
"name": "metadata", "name": "metadata",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "real", "type": "real",
@@ -1388,7 +1242,7 @@
"generated": null, "generated": null,
"name": "cost", "name": "cost",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "integer", "type": "integer",
@@ -1398,7 +1252,7 @@
"generated": null, "generated": null,
"name": "tokens_input", "name": "tokens_input",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "integer", "type": "integer",
@@ -1408,7 +1262,7 @@
"generated": null, "generated": null,
"name": "tokens_output", "name": "tokens_output",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "integer", "type": "integer",
@@ -1418,7 +1272,7 @@
"generated": null, "generated": null,
"name": "tokens_reasoning", "name": "tokens_reasoning",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "integer", "type": "integer",
@@ -1428,7 +1282,7 @@
"generated": null, "generated": null,
"name": "tokens_cache_read", "name": "tokens_cache_read",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "integer", "type": "integer",
@@ -1438,7 +1292,7 @@
"generated": null, "generated": null,
"name": "tokens_cache_write", "name": "tokens_cache_write",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1448,7 +1302,7 @@
"generated": null, "generated": null,
"name": "revert", "name": "revert",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1458,7 +1312,7 @@
"generated": null, "generated": null,
"name": "permission", "name": "permission",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1468,7 +1322,7 @@
"generated": null, "generated": null,
"name": "agent", "name": "agent",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "text", "type": "text",
@@ -1478,7 +1332,7 @@
"generated": null, "generated": null,
"name": "model", "name": "model",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "integer", "type": "integer",
@@ -1488,7 +1342,7 @@
"generated": null, "generated": null,
"name": "time_created", "name": "time_created",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "integer", "type": "integer",
@@ -1498,7 +1352,7 @@
"generated": null, "generated": null,
"name": "time_updated", "name": "time_updated",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "integer", "type": "integer",
@@ -1508,7 +1362,7 @@
"generated": null, "generated": null,
"name": "time_compacting", "name": "time_compacting",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "integer", "type": "integer",
@@ -1518,7 +1372,7 @@
"generated": null, "generated": null,
"name": "time_archived", "name": "time_archived",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
}, },
{ {
"type": "integer", "type": "integer",
@@ -1528,67 +1382,7 @@
"generated": null, "generated": null,
"name": "time_suspended", "name": "time_suspended",
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session_v2"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "secret",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "session_share"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_share"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_share"
}, },
{ {
"columns": [ "columns": [
@@ -1669,14 +1463,14 @@
"columns": [ "columns": [
"session_id" "session_id"
], ],
"tableTo": "session", "tableTo": "session_v2",
"columnsTo": [ "columnsTo": [
"id" "id"
], ],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
"name": "fk_instruction_entry_session_id_session_id_fk", "name": "fk_instruction_entry_session_id_session_v2_id_fk",
"entityType": "fks", "entityType": "fks",
"table": "instruction_entry" "table": "instruction_entry"
}, },
@@ -1684,14 +1478,14 @@
"columns": [ "columns": [
"session_id" "session_id"
], ],
"tableTo": "session", "tableTo": "session_v2",
"columnsTo": [ "columnsTo": [
"id" "id"
], ],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
"name": "fk_instruction_state_session_id_session_id_fk", "name": "fk_instruction_state_session_id_session_v2_id_fk",
"entityType": "fks", "entityType": "fks",
"table": "instruction_state" "table": "instruction_state"
}, },
@@ -1699,44 +1493,14 @@
"columns": [ "columns": [
"session_id" "session_id"
], ],
"tableTo": "session", "tableTo": "session_v2",
"columnsTo": [ "columnsTo": [
"id" "id"
], ],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
"name": "fk_message_session_id_session_id_fk", "name": "fk_session_message_session_id_session_v2_id_fk",
"entityType": "fks",
"table": "message"
},
{
"columns": [
"message_id"
],
"tableTo": "message",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_part_message_id_message_id_fk",
"entityType": "fks",
"table": "part"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_message_session_id_session_id_fk",
"entityType": "fks", "entityType": "fks",
"table": "session_message" "table": "session_message"
}, },
@@ -1744,14 +1508,14 @@
"columns": [ "columns": [
"session_id" "session_id"
], ],
"tableTo": "session", "tableTo": "session_v2",
"columnsTo": [ "columnsTo": [
"id" "id"
], ],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
"name": "fk_session_input_session_id_session_id_fk", "name": "fk_session_pending_session_id_session_v2_id_fk",
"entityType": "fks", "entityType": "fks",
"table": "session_pending" "table": "session_pending"
}, },
@@ -1766,24 +1530,9 @@
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
"name": "fk_session_project_id_project_id_fk", "name": "fk_session_v2_project_id_project_id_fk",
"entityType": "fks", "entityType": "fks",
"table": "session" "table": "session_v2"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_share_session_id_session_id_fk",
"entityType": "fks",
"table": "session_share"
}, },
{ {
"columns": [ "columns": [
@@ -1824,15 +1573,6 @@
"table": "workspace", "table": "workspace",
"entityType": "pks" "entityType": "pks"
}, },
{
"columns": [
"name"
],
"nameExplicit": false,
"name": "data_migration_pk",
"table": "data_migration",
"entityType": "pks"
},
{ {
"columns": [ "columns": [
"id" "id"
@@ -1923,24 +1663,6 @@
"table": "instruction_state", "table": "instruction_state",
"entityType": "pks" "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": [ "columns": [
"id" "id"
@@ -1955,7 +1677,7 @@
"id" "id"
], ],
"nameExplicit": false, "nameExplicit": false,
"name": "session_input_pk", "name": "session_pending_pk",
"table": "session_pending", "table": "session_pending",
"entityType": "pks" "entityType": "pks"
}, },
@@ -1964,17 +1686,8 @@
"id" "id"
], ],
"nameExplicit": false, "nameExplicit": false,
"name": "session_pk", "name": "session_v2_pk",
"table": "session", "table": "session_v2",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
"entityType": "pks" "entityType": "pks"
}, },
{ {
@@ -2039,60 +1752,6 @@
"entityType": "indexes", "entityType": "indexes",
"table": "permission" "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": [ "columns": [
{ {
@@ -2233,9 +1892,9 @@
"isUnique": false, "isUnique": false,
"where": null, "where": null,
"origin": "manual", "origin": "manual",
"name": "session_project_idx", "name": "session_v2_project_idx",
"entityType": "indexes", "entityType": "indexes",
"table": "session" "table": "session_v2"
}, },
{ {
"columns": [ "columns": [
@@ -2247,9 +1906,9 @@
"isUnique": false, "isUnique": false,
"where": null, "where": null,
"origin": "manual", "origin": "manual",
"name": "session_workspace_idx", "name": "session_v2_workspace_idx",
"entityType": "indexes", "entityType": "indexes",
"table": "session" "table": "session_v2"
}, },
{ {
"columns": [ "columns": [
@@ -2261,9 +1920,9 @@
"isUnique": false, "isUnique": false,
"where": null, "where": null,
"origin": "manual", "origin": "manual",
"name": "session_parent_idx", "name": "session_v2_parent_idx",
"entityType": "indexes", "entityType": "indexes",
"table": "session" "table": "session_v2"
}, },
{ {
"columns": [ "columns": [
@@ -2273,11 +1932,11 @@
} }
], ],
"isUnique": false, "isUnique": false,
"where": "\"session\".\"time_suspended\" is not null", "where": "\"session_v2\".\"time_suspended\" is not null",
"origin": "manual", "origin": "manual",
"name": "session_time_suspended_idx", "name": "session_v2_time_suspended_idx",
"entityType": "indexes", "entityType": "indexes",
"table": "session" "table": "session_v2"
} }
], ],
"renames": [] "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)
+8 -4
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs/promises" import fs from "fs/promises"
import os from "os" import os from "os"
import path from "path" import path from "path"
@@ -100,9 +99,14 @@ async function drizzle(temporary: string, output: string, name?: string) {
export default { ...config, out: ${JSON.stringify(output)} } export default { ...config, out: ${JSON.stringify(output)} }
`, `,
) )
await $`bun drizzle-kit generate --config ${config} ${name ? ["--name", name] : []}`.cwd( const child = Bun.spawn(["bun", "drizzle-kit", "generate", "--config", config, ...(name ? ["--name", name] : [])], {
path.join(root, "packages/core"), 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) { async function generatedMigrations(directory: string) {
-4
View File
@@ -44,7 +44,6 @@ export type Draft = {
export interface Interface extends State.Transformable<Draft> { export interface Interface extends State.Transformable<Draft> {
readonly get: (id: ID) => Effect.Effect<Info | undefined> readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined> readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
readonly select: (id?: ID | string) => Effect.Effect<Selection> readonly select: (id?: ID | string) => Effect.Effect<Selection>
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
@@ -110,9 +109,6 @@ const layer = Layer.effect(
get: Effect.fn("Agent.get")(function* (id) { get: Effect.fn("Agent.get")(function* (id) {
return state.get().agents.get(id) return state.get().agents.get(id)
}), }),
default: Effect.fn("Agent.default")(function* () {
return selectedDefault()
}),
resolve: Effect.fn("Agent.resolve")(function* (id) { resolve: Effect.fn("Agent.resolve")(function* (id) {
if (id !== undefined) return state.get().agents.get(ID.make(id)) if (id !== undefined) return state.get().agents.get(ID.make(id))
return selectedDefault() return selectedDefault()
+45 -53
View File
@@ -3,7 +3,7 @@ export * as Bus from "./bus"
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log" import type { EventLog } from "@opencode-ai/schema/event-log"
import { and, asc, eq, gt, inArray, lte, sql } from "drizzle-orm" import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
import { Database } from "./database/database" import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql" import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location" import { Location } from "./location"
@@ -134,8 +134,6 @@ export interface Interface {
readonly after?: number readonly after?: number
readonly follow?: boolean readonly follow?: boolean
}) => Stream.Stream<LogItem> }) => Stream.Stream<LogItem>
/** Latest committed seq per aggregate. Aggregates without events are absent. */
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Event.Seq>>
/** @deprecated Use `subscribe()` and consume the returned stream. */ /** @deprecated Use `subscribe()` and consume the returned stream. */
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe> readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
readonly project: <D extends Event.Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void> readonly project: <D extends Event.Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
@@ -154,16 +152,19 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export interface LayerOptions { interface Options {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void> readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
/** Maximum durable rows read per page while replaying or tailing an aggregate log. */ /** Maximum durable rows read per page while replaying or tailing an aggregate log. */
readonly logReadPageSize?: number readonly logReadPageSize?: number
/** Retain durable event payloads for historical log reads and replay. */
readonly persist?: boolean
} }
export const layerWith = (options?: LayerOptions) => export function configured(options?: Options) {
Layer.effect( return makeGlobalNode({
Service, service: Service,
Effect.gen(function* () { deps: [Database.node],
layer: Layer.effect(Service, Effect.gen(function* () {
const pubsub = { const pubsub = {
live: yield* PubSub.unbounded<Event.Payload>(), live: yield* PubSub.unbounded<Event.Payload>(),
durable: new Map<string, Set<PubSub.PubSub<void>>>(), durable: new Map<string, Set<PubSub.PubSub<void>>>(),
@@ -173,6 +174,7 @@ export const layerWith = (options?: LayerOptions) =>
const listeners = new Array<Subscriber>() const listeners = new Array<Subscriber>()
const { db } = yield* Database.Service const { db } = yield* Database.Service
const logReadPageSize = options?.logReadPageSize ?? 512 const logReadPageSize = options?.logReadPageSize ?? 512
const persist = options?.persist ?? false
const getOrCreate = (definition: Event.Definition) => const getOrCreate = (definition: Event.Definition) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -253,6 +255,7 @@ export const layerWith = (options?: LayerOptions) =>
) )
} }
if (input && input.seq <= latest) { if (input && input.seq <= latest) {
if (!persist) return
const stored = yield* db const stored = yield* db
.select() .select()
.from(EventTable) .from(EventTable)
@@ -294,19 +297,21 @@ export const layerWith = (options?: LayerOptions) =>
}), }),
) )
} }
const stored = yield* db if (persist) {
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq }) const stored = yield* db
.from(EventTable) .select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.where(eq(EventTable.id, event.id)) .from(EventTable)
.get() .where(eq(EventTable.id, event.id))
.pipe(Effect.orDie) .get()
if (stored) .pipe(Effect.orDie)
yield* Effect.die( if (stored)
new InvalidDurableEventError({ yield* Effect.die(
type: event.type, new InvalidDurableEventError({
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`, type: event.type,
}), message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
) }),
)
}
const committed = { const committed = {
...event, ...event,
durable: { aggregateID, seq, version: durable.version }, durable: { aggregateID, seq, version: durable.version },
@@ -327,20 +332,21 @@ export const layerWith = (options?: LayerOptions) =>
}) })
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* db if (persist)
.insert(EventTable) yield* db
.values([ .insert(EventTable)
{ .values([
id: event.id, {
aggregate_id: aggregateID, id: event.id,
seq, aggregate_id: aggregateID,
created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)), seq,
type: versionedType(definition.type, durable.version), created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)),
data: encoded, type: versionedType(definition.type, durable.version),
}, data: encoded,
]) },
.run() ])
.pipe(Effect.orDie) .run()
.pipe(Effect.orDie)
return { aggregateID, seq } return { aggregateID, seq }
}), }),
{ behavior: "immediate" }, { behavior: "immediate" },
@@ -657,19 +663,6 @@ export const layerWith = (options?: LayerOptions) =>
}), }),
) )
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Event.Seq>> => {
if (aggregateIDs.length === 0) return Effect.succeed(new Map())
return db
.select({ aggregateID: EventSequenceTable.aggregate_id, seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(inArray(EventSequenceTable.aggregate_id, Array.from(aggregateIDs)))
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Event.Seq.make(row.seq)]))),
)
}
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> => const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
Effect.sync(() => { Effect.sync(() => {
listeners.push(listener) listeners.push(listener)
@@ -691,7 +684,6 @@ export const layerWith = (options?: LayerOptions) =>
publish, publish,
subscribe, subscribe,
log, log,
sequences,
listen, listen,
project, project,
replay, replay,
@@ -699,8 +691,8 @@ export const layerWith = (options?: LayerOptions) =>
remove, remove,
claim, claim,
}) })
}), })),
) })
}
export const layer = layerWith() export const node = configured()
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
-6
View File
@@ -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
View File
@@ -40,24 +40,7 @@ export const migrations = (
import("./migration/20260622142730_simplify_session_context_epoch"), import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"), import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"), import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260702134641_add_session_context_entry"), import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260703090000_reset_v2_event_rename_sweep"), import("./migration/20260805200742_import_legacy_credentials"),
import("./migration/20260703181610_event_created_column"),
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
import("./migration/20260703200000_reset_v2_session_events"),
import("./migration/20260705180000_rename_instructions"),
import("./migration/20260706223930_add-session-fork"),
import("./migration/20260707010146_durable_session_inbox"),
import("./migration/20260707120000_migrate_prelaunch_v2_state"),
import("./migration/20260709013000_generic_session_input"),
import("./migration/20260709025533_drop-todo"),
import("./migration/20260709163752_time_suspended"),
import("./migration/20260709190621_session_pending_table"),
import("./migration/20260710025429_instruction_sync"),
import("./migration/20260716020354_kv"),
import("./migration/20260722011141_delete_tool_progress_events"),
import("./migration/20260722170000_canonical_tool_results"),
import("./migration/20260729022634_session_fork_boundary"),
import("./migration/20260730195856_optional_session_title"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).map((module) => module.default) satisfies DatabaseMigration.Migration[]
+43 -2
View File
@@ -12,6 +12,7 @@ const lock = Semaphore.makeUnsafe(1)
export type Migration = { export type Migration = {
id: string id: string
foreignKeys?: boolean
up: (tx: Transaction) => Effect.Effect<void, unknown> up: (tx: Transaction) => Effect.Effect<void, unknown>
} }
@@ -21,8 +22,11 @@ export function apply(db: Database) {
const tables = yield* db.all<{ name: string }>( const tables = yield* db.all<{ name: string }>(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`, 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")) 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) => yield* db.transaction((tx) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* schema.up(tx) 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) { for (const migration of input) {
if (completed.has(migration.id)) continue 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* () { Effect.gen(function* () {
yield* migration.up(tx) yield* migration.up(tx)
yield* tx.run( 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 \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`) yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`) 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 } 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

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