mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 16:41:01 -04:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecf5ad692d | |||
| cc12884986 | |||
| e070eda568 | |||
| ec49161441 | |||
| ecf9d3c04c | |||
| 624fc21b32 | |||
| 934935963d | |||
| f3912a2a8a | |||
| 45d58717a4 | |||
| 74e3155ef0 | |||
| 0af6c82563 | |||
| 686127f809 | |||
| 5256655c4d | |||
| d10b652637 | |||
| b03ca0d4e2 | |||
| 25aaea3d31 | |||
| cae7a139bc | |||
| 5ea62ab05f | |||
| faadc05c88 | |||
| 3e253c589e | |||
| 0a0fc09533 | |||
| 5aa0413fea | |||
| 6f4c199629 | |||
| ed8e1f4654 | |||
| 3b0195e045 | |||
| 143a776373 |
@@ -5,8 +5,27 @@
|
||||
- Use the `dev` branch database schema and migration registry as the V1 baseline.
|
||||
- Remove migrations that exist only on the V2 branch.
|
||||
- Generate one canonical migration from the `dev` schema to the final V2 schema.
|
||||
- Add explicit data operations to that migration where generated DDL is insufficient.
|
||||
- Test the migration against a populated database at the exact `dev` schema.
|
||||
- Keep the canonical migration focused on schema changes and dropping obsolete tables.
|
||||
- Run the V1 history backfill through an experimental server endpoint invoked by the CLI before it opens the TUI.
|
||||
- Show committed session progress while the endpoint runs.
|
||||
|
||||
Expose `GET /api/experimental/migration/v1` for status and a blocking `POST /api/experimental/migration/v1` to run or
|
||||
resume the backfill. The status is `required`, `running`, or `completed`. On startup, the CLI checks status first and
|
||||
renders no migration UI when it is already complete. For required or running status, it shows a spinner and waits for the
|
||||
blocking POST without a request timeout. While migration runs, poll GET once per second and render completed and total
|
||||
session counts. GET derives total from all session rows and completed from rows through the stored cursor; the count
|
||||
advances only after a session transaction commits. The POST returns `{ status: "completed" }`. Do not add a background
|
||||
job or streaming progress protocol. Interrupted calls resume from the stored cursor.
|
||||
Initially, only interactive TUI startup performs this check; noninteractive run, ACP, raw API, service, health, version,
|
||||
and help flows do not trigger the backfill.
|
||||
|
||||
Keep migration behavior in Core: status, semaphore, checkpointing, V1 decoding, transformation, and database writes.
|
||||
Protocol owns the experimental GET/POST contracts, Server handlers delegate to Core, and the interactive CLI owns only
|
||||
the status check and spinner presentation.
|
||||
|
||||
Guard the endpoint with one process-local Effect `Semaphore`. Concurrent callers wait; after the active call completes,
|
||||
waiting callers acquire the permit, observe the completion key, and return immediately. No distributed lock is required
|
||||
for the current single elected server process.
|
||||
|
||||
## Preserve
|
||||
|
||||
@@ -15,20 +34,35 @@ The canonical V1 data remains in its existing tables. In particular, preserve `s
|
||||
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
|
||||
workspace relationships.
|
||||
|
||||
Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the
|
||||
generated migration must not drop the table.
|
||||
Preserve existing non-null `session.agent` and `session.model` selections. Fill missing values from the latest ordinary
|
||||
V1 user message ordered by `time_created` and `id`, excluding compaction and subtask-only messages. Copy agent, provider
|
||||
ID, model ID, and variant, normalizing an absent variant to `default`.
|
||||
|
||||
## Truncate
|
||||
Recompute session usage aggregates from all canonical V1 assistant messages, including compaction or other internal
|
||||
assistants omitted from the V2 projection. Overwrite session cost and input, output, reasoning, cache-read, and
|
||||
cache-write token totals with those sums.
|
||||
|
||||
Truncate these pre-launch V2 tables before applying schema changes:
|
||||
Clear persisted `session.revert` state. A staged revert is transient operational state and may refer to omitted projection
|
||||
rows or unavailable snapshots; it must not resume automatically after upgrading. Preserve the underlying messages,
|
||||
parts, and file history.
|
||||
|
||||
- `event`
|
||||
- `event_sequence`
|
||||
- `session_message`
|
||||
Clear `session.time_compacting`, leave the new `time_suspended` column as `NULL`, and preserve session creation, update,
|
||||
and archive timestamps. Preserve project `time_initialized`; it is unrelated durable state.
|
||||
|
||||
These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the
|
||||
column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message`
|
||||
and `part` rows rather than retaining its pre-launch V2 contents.
|
||||
Keep the legacy `todo` table and its data physically unchanged, but do not include it in the final V2 Drizzle schema.
|
||||
After generation, remove the generated `DROP TABLE todo` statement from the canonical migration so the table remains as
|
||||
unmanaged legacy storage.
|
||||
|
||||
## Per-Session Replacement
|
||||
|
||||
Do not truncate `event`, `event_sequence`, or `session_message` globally before the backfill. A whole-table delete can
|
||||
hold SQLite's writer lock long enough to block the running TUI.
|
||||
|
||||
Replace each legacy session's V2 state inside that session's checkpointed migration transaction. Delete `event` rows for
|
||||
the session aggregate, delete its `session_message` rows, rebuild its projection from canonical V1 `message` and `part`
|
||||
rows, and overwrite its `event_sequence` watermark. If migration of that session fails, all replacements roll back and
|
||||
the durable cursor remains at the previously committed session. Rows owned by sessions outside the legacy migration set
|
||||
remain untouched.
|
||||
|
||||
## Message Backfill
|
||||
|
||||
@@ -36,9 +70,23 @@ Backfill canonical V1 history from `message` and `part` into `session_message`.
|
||||
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
|
||||
V2 session APIs, which read `session_message`.
|
||||
|
||||
Do not fail the whole migration when a V1 message or part payload cannot be decoded. Skip an undecodable message's V2
|
||||
projection and log its session and message IDs. Skip an undecodable part while continuing to map its message, and perform
|
||||
special-message pairing only with decoded rows. Assign sequences after filtering. Leave every malformed source row
|
||||
untouched in the V1 tables.
|
||||
|
||||
Skip and log orphan parts whose source message does not exist and parts with unknown or unsupported types. Continue
|
||||
migrating the owning message and other valid parts. Include session, message, part ID, and observed type in warnings, and
|
||||
leave skipped source rows unchanged.
|
||||
|
||||
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
|
||||
avoid rewriting other persisted state that may refer to a message.
|
||||
|
||||
For ordinary user and assistant rows, preserve source `message.time_created` and `message.time_updated`. Entirely
|
||||
synthetic messages preserve their source timestamps, and synthetic rows split from mixed messages use the source user
|
||||
timestamps. A collapsed compaction uses the compaction user creation time and the later update time of the compaction
|
||||
user and summary assistant. Keep payload creation/completion times consistent with row timestamps.
|
||||
|
||||
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
|
||||
contiguous `session_message.seq` values starting at `0`.
|
||||
|
||||
@@ -46,15 +94,122 @@ Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becom
|
||||
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
|
||||
payload.
|
||||
|
||||
Keep ordinary messages even when their transformed payload becomes empty after filtering. Preserve an empty V2 user row
|
||||
with `text: ""` and an empty V2 assistant row with `content: []` so IDs, chronology, and conversation structure remain
|
||||
stable. Omit only explicitly dropped internal concepts and undecodable messages.
|
||||
|
||||
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
|
||||
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
|
||||
part mappings must be decided explicitly before implementing the backfill.
|
||||
|
||||
Do not carry the V1 subtask concept into the V2 projection. Omit user messages containing only `subtask` parts and omit
|
||||
the paired assistant task-tool messages generated from those markers. For mixed user messages, ignore the `subtask`
|
||||
parts while preserving ordinary content, and still omit assistant task-tool messages generated by the skipped subtasks.
|
||||
Keep all source rows unchanged in the V1 `message` and `part` tables.
|
||||
|
||||
Map ordinary V1 assistant `text` and `reasoning` parts into the V2 assistant `content` array in part order. Preserve text,
|
||||
including empty assistant text parts used as structural separators. Map V1 part metadata to optional V2 provider state.
|
||||
For reasoning, map `time.start` to `time.created` and optional `time.end` to `time.completed`.
|
||||
|
||||
Preserve V1 tool parts that are `pending` or `running`, but convert them to terminal V2 tool error states. Preserve the
|
||||
call ID, tool name, parsed input, metadata, and available start time. Use the assistant message creation time when the V1
|
||||
state has no start time. Set the error to type `tool.interrupted` with message
|
||||
`Tool execution was interrupted before V2 migration`. Never resume migrated tool executions.
|
||||
|
||||
For a completed V1 tool part, use `callID` as the V2 tool content ID and preserve the tool name and parsed input. Set the
|
||||
state to `completed`. Convert V1 output into the first text content item and convert stored output attachments into
|
||||
following file content items with their URI, MIME type, and filename. Preserve state metadata. Map `time.start` to
|
||||
`time.created` and `time.end` to `time.completed`. When `time.compacted` exists, use
|
||||
`[Old tool result content cleared]` as the only output and omit attachments.
|
||||
|
||||
For a failed V1 tool part, preserve the call ID, tool name, parsed input, metadata, and timestamps, and set the V2 state
|
||||
to `error`. Convert the V1 error string to a structured error with type `tool.execution`. If V1 metadata contains a string
|
||||
`output`, preserve it as optional V2 text content. Map `time.start` to `time.created` and `time.end` to `time.completed`.
|
||||
|
||||
For an ordinary V1 assistant message, preserve agent, provider ID, model ID, optional variant, creation and completion
|
||||
times, cost, and input/output/reasoning/cache token counts. Use `default` when the V1 variant is absent. Ignore V1
|
||||
`tokens.total` because it is derivable and V2 does not persist it.
|
||||
|
||||
Use V1 assistant `parentID` only while pairing compactions and skipped subtasks with their originating user messages. Do
|
||||
not persist it in ordinary V2 assistant rows; V2 uses ordered history rather than user/assistant parent links.
|
||||
|
||||
Ignore the optional V1 assistant `structured` output value. V2 has no equivalent top-level assistant field, and visible
|
||||
text and tool content are migrated separately. Retain the original structured value only in the V1 `message` row.
|
||||
|
||||
Ignore V1 assistant `mode` and historical `path` (`cwd` and `root`). Mode is redundant with the preserved assistant
|
||||
agent, and historical filesystem paths do not belong to the V2 assistant message contract. Retain them only in the V1
|
||||
`message` row.
|
||||
|
||||
For assistant finish reasons, preserve `stop`, `length`, `tool-calls`, `content-filter`, `error`, and `unknown`. Map every
|
||||
other nonempty V1 finish value to `unknown`, and leave the field absent when V1 omitted it. Do not retain unrecognized raw
|
||||
finish values in metadata.
|
||||
|
||||
Map V1 assistant errors into the current V2 `{ type, message }` storage shape. Normalize Auth, content-filter, context
|
||||
overflow, structured-output, output-length, aborted, API, and unknown errors to the established V2 string conventions,
|
||||
preserve the message, and discard V1-only retryability and raw provider details.
|
||||
|
||||
Ignore V1 `retry` parts. Do not populate the V2 assistant `retry` field during migration; historical retry state is not
|
||||
useful enough to preserve. The original retry rows remain in the V1 `part` table.
|
||||
|
||||
Do not emit V2 assistant content for V1 `step-start` and `step-finish` parts. Use the first available
|
||||
`step-start.snapshot` as `assistant.snapshot.start` and the last available `step-finish.snapshot` as
|
||||
`assistant.snapshot.end`. Continue to source finish, cost, and tokens from the assistant message itself. Ignore step
|
||||
markers without snapshots.
|
||||
|
||||
Do not emit assistant content for standalone V1 `snapshot` or `patch` parts. If no start snapshot came from `step-start`,
|
||||
use the first standalone snapshot value, then the first patch hash as a final fallback. Only `step-finish.snapshot` may
|
||||
populate the end snapshot. Merge patch file lists into `assistant.snapshot.files` in first-seen order with duplicates
|
||||
removed.
|
||||
|
||||
V2 follow-up: replace the open `SessionError.Error` string shape with a properly typed persisted error union. This is not
|
||||
a blocker for the V1 migration, which should target the current storage contract.
|
||||
|
||||
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
|
||||
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
|
||||
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
|
||||
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
|
||||
|
||||
For an ordinary V2 user message, take visible V1 text parts that are neither ignored nor synthetic, preserve part order,
|
||||
and join their text with `"\n\n"`. Use an empty string when the message contains attachments but no ordinary text.
|
||||
|
||||
Ignore the optional V1 user-message `system` override. Do not create a V2 system message or preserve the override in
|
||||
metadata. The original value remains in the V1 `message` row.
|
||||
|
||||
Ignore the optional V1 user-message `tools` map. It represented request-time tool enablement for a historical step and
|
||||
must not affect future V2 execution. The original value remains in the V1 `message` row.
|
||||
|
||||
Ignore the optional V1 user-message `format` field and its schema. It controlled structured-output behavior for a
|
||||
historical request and must not affect future V2 runs. Preserve visible assistant text normally; retain the original
|
||||
format only in the V1 `message` row.
|
||||
|
||||
Ignore V1 user-message `summary` metadata, including title, body, and diffs. V2 user messages have no equivalent field,
|
||||
and session-level summary data is already persisted separately. Retain the original summary only in the V1 `message`
|
||||
row.
|
||||
|
||||
Map V1 `agent` parts into the V2 user message's `agents` array in part order. Preserve `name`. When the V1 part has
|
||||
`source`, map its `value`, `start`, and `end` into the V2 attachment's `mention.text`, `mention.start`, and `mention.end`.
|
||||
Omit `agents` when there are no agent parts.
|
||||
|
||||
Do not read the filesystem or network while migrating V1 file attachments. Attachment migration must be deterministic
|
||||
from database contents alone. Convert persisted `data:` URLs; represent non-embedded `file:`, HTTP, and other external
|
||||
URLs with deterministic text rather than fetching them. Keep the original V1 `part` rows unchanged.
|
||||
|
||||
For a V1 file backed by a `data:` URL, decode the URL and normalize its payload to base64 for the V2 attachment's `data`.
|
||||
Preserve `mime` and optional `filename` as `name`. Use a V2 `uri` source with the original URI for a V1 resource source;
|
||||
otherwise use an `inline` source. When V1 source text metadata exists, map its `value`, `start`, and `end` into the V2
|
||||
attachment mention. Leave `description` unset and preserve file-part order in the V2 `files` array.
|
||||
|
||||
For a non-embedded V1 file, do not create a V2 file attachment. Append
|
||||
`[Attachment unavailable after migration: <name-or-url> (<mime>)]` to the V2 user text in original part order, separated
|
||||
by blank lines. Prefer the V1 filename, then resource URI, then part URL for the label. The original URL remains only in
|
||||
the preserved V1 `part` row.
|
||||
|
||||
For a synthetic row split from a mixed user message, derive a generated-looking ID from the source message ID. Preserve
|
||||
the source ID's 12-character timestamp component and replace its 14-character random component with a deterministic
|
||||
base-62 encoding of a hash of `v1-synthetic:` plus the source message ID. If that candidate collides with an existing or
|
||||
derived message ID, deterministically retry with an incrementing salt. Place the synthetic row immediately after its
|
||||
source user row. Entirely synthetic messages continue to reuse their original message ID.
|
||||
|
||||
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
|
||||
admitted compaction input ID and preserves references to the initiating message.
|
||||
|
||||
@@ -64,9 +219,13 @@ serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an
|
||||
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
|
||||
assistant row.
|
||||
|
||||
After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that
|
||||
session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting
|
||||
before migrated history. The `event` table remains empty.
|
||||
Do not project incomplete or failed V1 compactions into `session_message`. Omit both the internal compaction user marker
|
||||
and its paired summary assistant when no successful summary was completed. Assign final sequence numbers after filtering
|
||||
so omitted compactions leave no gaps. Their source rows remain preserved in the V1 `message` and `part` tables.
|
||||
|
||||
After rebuilding a session's `session_message`, replace its `event_sequence` watermark with that session's maximum
|
||||
backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting before migrated
|
||||
history. The migrated session's prior `event` rows are removed in the same transaction.
|
||||
|
||||
## Drop
|
||||
|
||||
@@ -74,6 +233,7 @@ Drop these pre-launch V2 tables without preserving or transforming their rows:
|
||||
|
||||
- `session_input`
|
||||
- `session_context_epoch`
|
||||
- `data_migration`
|
||||
|
||||
Do not transfer `session_input` rows into `session_pending`.
|
||||
|
||||
@@ -103,16 +263,34 @@ schema.
|
||||
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
|
||||
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
|
||||
|
||||
## Verification
|
||||
## Execution
|
||||
|
||||
The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
|
||||
credentials, permissions, shares, and workspaces. After migration, it should verify:
|
||||
Before transforming V1 rows, look for `opencode-next.db` in the data directory. This file was used by pre-launch V2
|
||||
builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the
|
||||
current `project`, `session_v2`, and `session_message` tables. Existing current projects and Sessions win ID collisions.
|
||||
Do not copy its durable events or runtime caches; initialize each imported Session's `event_sequence` watermark from its
|
||||
maximum message sequence. Commit each imported Session independently and leave the source database untouched.
|
||||
|
||||
- Preserved rows and encoded values remain unchanged.
|
||||
- Todo rows remain available in the unchanged `todo` table.
|
||||
- `event` is empty, and stale pre-launch rows are absent from the rebuilt projections.
|
||||
- Backfilled `session_message` rows represent the canonical V1 `message` and `part` history.
|
||||
- Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence.
|
||||
- Dropped tables no longer exist.
|
||||
- New tables exist and are empty.
|
||||
- The final schema has no ungenerated changes.
|
||||
The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor:
|
||||
the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed.
|
||||
|
||||
Store V1 backfill state in `kv`; do not retain a dedicated `data_migration` table. Store the last successfully migrated
|
||||
session ID under `migration.v1-v2.session.cursor` and write `migration.v1-v2.completed` with value `true` after every
|
||||
session finishes. Delete the cursor key on completion and return immediately on later calls when the completion key
|
||||
exists.
|
||||
|
||||
Absence of the completion key means migration is required, including on a fresh database. Running the endpoint against a
|
||||
database with no sessions completes immediately and writes the completion key; fresh database initialization does not
|
||||
seed migration state specially.
|
||||
|
||||
Process sessions in stable ID order. Rebuild one session in one transaction, including its `session_message` rows,
|
||||
session-level backfills, `event_sequence` watermark, and cursor update. If interrupted during a session, that transaction
|
||||
rolls back and the next endpoint call retries the same session. If it committed, the next call continues after the stored
|
||||
cursor. Mark the migration complete after the final session and return immediately on later calls.
|
||||
|
||||
Process every `session` row, including archived, root, child, and empty sessions, as well as sessions whose messages are
|
||||
all skipped or internal. Each successfully committed session advances the cursor.
|
||||
|
||||
## Testing
|
||||
|
||||
Detailed migration test design is deferred until after the canonical migration is implemented.
|
||||
|
||||
@@ -248,11 +248,6 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
|
||||
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) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.isContentEditable) return true
|
||||
|
||||
@@ -286,13 +286,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
children: tree.children,
|
||||
expand: tree.expandDir,
|
||||
collapse: tree.collapseDir,
|
||||
toggle(input: string) {
|
||||
if (tree.dirState(input)?.expanded) {
|
||||
tree.collapseDir(input)
|
||||
return
|
||||
}
|
||||
tree.expandDir(input)
|
||||
},
|
||||
},
|
||||
get,
|
||||
load,
|
||||
|
||||
@@ -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 {
|
||||
return {
|
||||
...project,
|
||||
|
||||
@@ -753,9 +753,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
mobileSidebar: {
|
||||
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
|
||||
show() {
|
||||
setStore("mobileSidebar", "opened", true)
|
||||
},
|
||||
hide() {
|
||||
setStore("mobileSidebar", "opened", false)
|
||||
},
|
||||
@@ -961,33 +958,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
if (current.reviewOpen.includes(path)) return
|
||||
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)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -22,8 +22,6 @@ type TabsInput = {
|
||||
fileBrowser?: Accessor<boolean>
|
||||
}
|
||||
|
||||
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
|
||||
|
||||
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
|
||||
return input.opened && input.visible
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ for (const item of targets) {
|
||||
name: "parcel-watcher-binding",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
|
||||
contents: `import binding from ${JSON.stringify(parcelWatcherPackage)}; export default () => binding`,
|
||||
contents: `export default () => require(${JSON.stringify(parcelWatcherPackage)})`,
|
||||
loader: "js",
|
||||
}))
|
||||
},
|
||||
|
||||
+193
-38
@@ -1,4 +1,4 @@
|
||||
import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk"
|
||||
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
OpenCodeClient,
|
||||
@@ -37,6 +37,34 @@ export type TurnStart =
|
||||
| { readonly type: "skill"; 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 {
|
||||
return { name: "tool", input: {}, metadata: {}, content: [] }
|
||||
}
|
||||
@@ -50,8 +78,13 @@ export async function streamTurn(input: {
|
||||
readonly writeTextFile: boolean
|
||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||
readonly control: TurnControl
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
readonly connectionSignal?: AbortSignal
|
||||
readonly sessionSignal?: AbortSignal
|
||||
}): Promise<PromptResponse> {
|
||||
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 connected = await stream.next()
|
||||
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 executionError: { readonly type: string; readonly message: string } | undefined
|
||||
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"]) =>
|
||||
input.connection.sessionUpdate({ sessionId: input.sessionID, update: value })
|
||||
const notifyChild = async (child: ChildSession, value: ChildSessionEvent) => {
|
||||
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) {
|
||||
const next = await stream.next()
|
||||
if (next.done) throw new Error("event stream disconnected during prompt execution")
|
||||
const event = next.value
|
||||
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
|
||||
const tool = event.data.source?.id ? tools.get(event.data.source.id) : undefined
|
||||
if (event.type === "session.created") {
|
||||
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({
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
event,
|
||||
sessionID: input.sessionID,
|
||||
sessionID: event.data.sessionID,
|
||||
clientSessionID: input.sessionID,
|
||||
cwd: input.cwd,
|
||||
tool,
|
||||
...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}),
|
||||
})
|
||||
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
|
||||
.cancel({ sessionID: input.sessionID, formID: event.data.form.id })
|
||||
.catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}))
|
||||
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
|
||||
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
|
||||
continue
|
||||
}
|
||||
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
|
||||
if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue
|
||||
if (matchesStart(event, input.start)) {
|
||||
started = true
|
||||
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") {
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
await update({
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
await send({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: event.data.assistantMessageID,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
@@ -110,8 +197,8 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
await update({
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
await send({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: event.data.assistantMessageID,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
@@ -119,9 +206,14 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
tools.set(event.data.id, { name: event.data.name, input: {}, metadata: {}, content: [] })
|
||||
await update({
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
tools.set(toolKey(event.data.sessionID, event.data.id), {
|
||||
name: event.data.name,
|
||||
input: {},
|
||||
metadata: {},
|
||||
content: [],
|
||||
})
|
||||
await send({
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: event.data.id,
|
||||
@@ -133,11 +225,12 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
current.input = event.data.input
|
||||
tools.set(event.data.id, current)
|
||||
await update({
|
||||
tools.set(key, current)
|
||||
await send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
@@ -149,10 +242,10 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
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
|
||||
current.metadata = event.data.metadata
|
||||
await update({
|
||||
await send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
@@ -164,8 +257,9 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.success") {
|
||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
||||
tools.delete(event.data.id)
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
tools.delete(key)
|
||||
await syncEditedFiles({
|
||||
connection: input.connection,
|
||||
writeTextFile: input.writeTextFile,
|
||||
@@ -175,7 +269,7 @@ export async function streamTurn(input: {
|
||||
toolInput: current.input,
|
||||
metadata: event.data.metadata ?? {},
|
||||
}).catch(() => {})
|
||||
await update({
|
||||
await send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
@@ -188,9 +282,10 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.failed") {
|
||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
||||
tools.delete(event.data.id)
|
||||
await update({
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
tools.delete(key)
|
||||
await send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
@@ -205,13 +300,33 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.step.ended") {
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
finish = event.data.finish
|
||||
if (!child) {
|
||||
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
|
||||
}
|
||||
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 (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
|
||||
return "failed" as const
|
||||
}
|
||||
@@ -219,7 +334,13 @@ export async function streamTurn(input: {
|
||||
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 {
|
||||
await input.submit(control.admission.signal).catch((error) => {
|
||||
if (!control.cancelled) throw error
|
||||
@@ -233,6 +354,13 @@ export async function streamTurn(input: {
|
||||
}
|
||||
}
|
||||
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
|
||||
? await input.client.session
|
||||
.message({ sessionID: input.sessionID, messageID: assistantMessageID })
|
||||
@@ -250,11 +378,38 @@ export async function streamTurn(input: {
|
||||
await completed.catch(() => {})
|
||||
throw error
|
||||
} finally {
|
||||
streamController.abort()
|
||||
await stream.return?.(undefined).catch(() => {})
|
||||
if (!handedOff) await closeStream()
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
connection: Pick<AgentSideConnection, "sessionUpdate">,
|
||||
sessionID: string,
|
||||
|
||||
@@ -20,20 +20,28 @@ export async function replyPermission(input: {
|
||||
readonly connection: Connection
|
||||
readonly event: PermissionEvent
|
||||
readonly sessionID: string
|
||||
readonly clientSessionID?: string
|
||||
readonly cwd: string
|
||||
readonly tool?: Tool
|
||||
readonly toolCallPrefix?: string
|
||||
readonly titlePrefix?: string
|
||||
}) {
|
||||
const toolName = input.tool?.name ?? input.event.data.action
|
||||
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
||||
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
|
||||
.requestPermission({
|
||||
sessionId: input.sessionID,
|
||||
sessionId: input.clientSessionID ?? input.sessionID,
|
||||
toolCall: {
|
||||
...pendingToolCall({
|
||||
toolCallId: input.event.data.source?.id ?? input.event.data.id,
|
||||
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
|
||||
toolName,
|
||||
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
|
||||
state: {
|
||||
input: toolInput,
|
||||
title: prefixedTitle(input.titlePrefix, title),
|
||||
},
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
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: {
|
||||
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
|
||||
readonly writeTextFile: boolean
|
||||
|
||||
@@ -43,13 +43,21 @@ import { OPENCODE_VERSION } from "../version"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
|
||||
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"
|
||||
|
||||
export const AuthMethodID = "opencode-login"
|
||||
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
|
||||
Partial<Pick<AgentSideConnection, "writeTextFile">>
|
||||
Partial<Pick<AgentSideConnection, "writeTextFile" | "extNotification" | "signal">>
|
||||
|
||||
type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
@@ -64,6 +72,7 @@ type Catalog = {
|
||||
type Attached = {
|
||||
readonly id: string
|
||||
readonly cwd: string
|
||||
readonly abort: AbortController
|
||||
catalog: Catalog
|
||||
model: ModelRef
|
||||
modeID: string
|
||||
@@ -100,7 +109,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
const catalogs = new Map<string, Promise<Catalog>>()
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const active = new Map<string, TurnControl>()
|
||||
const capabilities = { writeTextFile: false }
|
||||
const capabilities = { writeTextFile: false, childSessionUpdates: false }
|
||||
|
||||
const catalog = (cwd: string) => {
|
||||
const cached = catalogs.get(cwd)
|
||||
@@ -119,11 +128,19 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
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 currentCatalog = await catalog(cwd)
|
||||
sessions.get(session.id)?.abort.abort()
|
||||
const state: Attached = {
|
||||
id: session.id,
|
||||
cwd,
|
||||
abort: new AbortController(),
|
||||
catalog: currentCatalog,
|
||||
model: session.model ?? currentCatalog.defaultModel,
|
||||
modeID: session.agent ?? currentCatalog.defaultModeID,
|
||||
@@ -161,6 +178,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
return {
|
||||
initialize: async (params) => {
|
||||
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
|
||||
capabilities.childSessionUpdates = params.clientCapabilities?._meta?.[ChildSessionUpdatesCapability] === true
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
name: "Login with opencode",
|
||||
@@ -178,6 +196,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
mcpCapabilities: { http: true, sse: false },
|
||||
promptCapabilities: { embeddedContext: true, image: true },
|
||||
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
||||
_meta: { [ChildSessionUpdatesCapability]: true },
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
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) => {
|
||||
if (!isSessionNotFoundError(error)) throw error
|
||||
})
|
||||
sessions.delete(params.sessionId)
|
||||
registeredMcp.delete(params.sessionId)
|
||||
detach(params.sessionId)
|
||||
return {}
|
||||
},
|
||||
resumeSession: async (params) => {
|
||||
@@ -234,8 +252,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
return { configOptions: configOptions(state) }
|
||||
},
|
||||
closeSession: async (params) => {
|
||||
sessions.delete(params.sessionId)
|
||||
registeredMcp.delete(params.sessionId)
|
||||
detach(params.sessionId)
|
||||
const turn = active.get(params.sessionId)
|
||||
if (turn) {
|
||||
turn.cancelled = true
|
||||
@@ -296,6 +313,11 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
const messageID = SessionMessage.ID.create()
|
||||
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
|
||||
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)
|
||||
const response = await streamTurn({
|
||||
client: input.client,
|
||||
@@ -305,7 +327,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
start: prepared.start,
|
||||
writeTextFile: capabilities.writeTextFile,
|
||||
control,
|
||||
connectionSignal: input.connection.signal,
|
||||
sessionSignal: state.abort.signal,
|
||||
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
||||
...(childSessionUpdate ? { childSessionUpdate } : {}),
|
||||
}).finally(() => {
|
||||
if (active.get(state.id) === control) active.delete(state.id)
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { Effect, FileSystem, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
@@ -81,7 +81,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
database: {
|
||||
path:
|
||||
process.env.OPENCODE_DB ??
|
||||
(["latest", "beta", "prod"].includes(OPENCODE_CHANNEL) ||
|
||||
(["latest", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
|
||||
? "opencode.db"
|
||||
@@ -108,9 +108,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
gitbash: process.env.OPENCODE_GIT_BASH_PATH,
|
||||
},
|
||||
fs: {
|
||||
filewatcher: !truthy(
|
||||
process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER,
|
||||
),
|
||||
filewatcher: !truthy(process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER),
|
||||
fff:
|
||||
process.env.OPENCODE_DISABLE_FFF === undefined
|
||||
? process.platform !== "win32"
|
||||
@@ -128,7 +126,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.provide(Logger.layer([], { mergeWithExisting: false })),
|
||||
Effect.catch((error) => {
|
||||
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
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"
|
||||
|
||||
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 () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
@@ -556,6 +731,7 @@ function turn(input: {
|
||||
readonly connection: Connection
|
||||
readonly sessionID: string
|
||||
readonly inputID: string
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
}) {
|
||||
return streamTurn({
|
||||
client: input.fixture.client,
|
||||
@@ -565,11 +741,23 @@ function turn(input: {
|
||||
start: { type: "input", id: input.inputID },
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
childSessionUpdate: input.childSessionUpdate,
|
||||
submit: (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() {
|
||||
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 () => {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
|
||||
const file = path.join(cwd, "file.ts")
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { ACPService } from "../../src/acp/service"
|
||||
import { ChildSessionUpdatesCapability } from "../../src/acp/event"
|
||||
|
||||
describe("acp service", () => {
|
||||
test("creates a v2 session, registers mcp, and publishes commands", async () => {
|
||||
@@ -39,11 +40,17 @@ describe("acp service", () => {
|
||||
})
|
||||
|
||||
try {
|
||||
const initialized = await service.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { _meta: { [ChildSessionUpdatesCapability]: true } },
|
||||
clientInfo: { name: "test", version: "1" },
|
||||
})
|
||||
const result = await service.newSession({
|
||||
cwd: "/workspace",
|
||||
mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }],
|
||||
})
|
||||
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(requests).toContainEqual({
|
||||
method: "PUT",
|
||||
|
||||
@@ -283,6 +283,26 @@ export type Endpoint5_26Input = {
|
||||
}
|
||||
export type Endpoint5_26Output =
|
||||
| (
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.created"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly projectID: Project.ID
|
||||
readonly location: Location.Ref
|
||||
readonly subpath?: RelativePath | undefined
|
||||
readonly parentID?: Session.ID | undefined
|
||||
readonly slug: string
|
||||
readonly title?: string | undefined
|
||||
readonly agent?: Agent.ID | undefined
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly version: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
@@ -420,6 +440,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly delta: { readonly [x: string]: (string & Brand.Brand<"Instruction.Hash">) | "removed" }
|
||||
readonly text?: string | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1538,19 +1559,33 @@ export interface DebugApi<E = never> {
|
||||
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint27_0Input = {
|
||||
export type Endpoint27_0Output = {
|
||||
readonly status: "required" | "running" | "completed"
|
||||
readonly completed: number
|
||||
readonly total: number
|
||||
}
|
||||
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
|
||||
|
||||
export type Endpoint27_1Output = { readonly status: "completed" }
|
||||
export type MigrationV1RunOperation<E = never> = () => Effect.Effect<Endpoint27_1Output, E>
|
||||
|
||||
export interface MigrationApi<E = never> {
|
||||
readonly v1: { readonly status: MigrationV1StatusOperation<E>; readonly run: MigrationV1RunOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint28_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
|
||||
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
||||
|
||||
export type Endpoint27_1Input = {
|
||||
export type Endpoint28_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly query: string
|
||||
readonly providerID?: WebSearch.ID | undefined
|
||||
}
|
||||
export type Endpoint27_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
|
||||
export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint28_1Input) => Effect.Effect<Endpoint28_1Output, E>
|
||||
|
||||
export interface WebsearchApi<E = never> {
|
||||
readonly providers: WebsearchProvidersOperation<E>
|
||||
@@ -1585,5 +1620,6 @@ export interface AppApi<E = never> {
|
||||
readonly projectCopy: ProjectCopyApi<E>
|
||||
readonly vcs: VcsApi<E>
|
||||
readonly debug: DebugApi<E>
|
||||
readonly migration: MigrationApi<E>
|
||||
readonly websearch: WebsearchApi<E>
|
||||
}
|
||||
|
||||
@@ -215,10 +215,12 @@ import type {
|
||||
Endpoint26_0Output,
|
||||
Endpoint26_1Input,
|
||||
Endpoint26_1Output,
|
||||
Endpoint27_0Input,
|
||||
Endpoint27_0Output,
|
||||
Endpoint27_1Input,
|
||||
Endpoint27_1Output,
|
||||
Endpoint28_0Input,
|
||||
Endpoint28_0Output,
|
||||
Endpoint28_1Input,
|
||||
Endpoint28_1Output,
|
||||
} from "../api/api.js"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -1217,22 +1219,32 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
|
||||
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
|
||||
})
|
||||
|
||||
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
|
||||
preserveEffect<Endpoint27_0Output>()(
|
||||
const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
|
||||
preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const Endpoint27_1 = (raw: RawClient["server.migration"]) => () =>
|
||||
preserveEffect<Endpoint27_1Output>()(raw["migration.v1.run"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({
|
||||
v1: { status: Endpoint27_0(raw), run: Endpoint27_1(raw) },
|
||||
})
|
||||
|
||||
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
|
||||
preserveEffect<Endpoint28_0Output>()(
|
||||
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
|
||||
preserveEffect<Endpoint27_1Output>()(
|
||||
const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
|
||||
preserveEffect<Endpoint28_1Output>()(
|
||||
raw["websearch.query"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { query: input["query"], providerID: input["providerID"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint27_0(raw),
|
||||
query: Endpoint27_1(raw),
|
||||
const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint28_0(raw),
|
||||
query: Endpoint28_1(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
@@ -1263,7 +1275,8 @@ const adaptClient = (raw: RawClient) => ({
|
||||
projectCopy: adaptGroup24(raw["server.projectCopy"]),
|
||||
vcs: adaptGroup25(raw["server.vcs"]),
|
||||
debug: adaptGroup26(raw["server.debug"]),
|
||||
websearch: adaptGroup27(raw["server.websearch"]),
|
||||
migration: adaptGroup27(raw["server.migration"]),
|
||||
websearch: adaptGroup28(raw["server.websearch"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
||||
@@ -211,6 +211,8 @@ import type {
|
||||
DebugLocationListOutput,
|
||||
DebugLocationEvictInput,
|
||||
DebugLocationEvictOutput,
|
||||
MigrationV1StatusOutput,
|
||||
MigrationV1RunOutput,
|
||||
WebsearchProvidersInput,
|
||||
WebsearchProvidersOutput,
|
||||
WebsearchQueryInput,
|
||||
@@ -492,7 +494,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -718,7 +720,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/context`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 500, 400, 401],
|
||||
declaredStatuses: [404, 500, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -730,7 +732,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -793,7 +795,7 @@ export function make(options: ClientOptions) {
|
||||
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/log`,
|
||||
query: { after: input["after"], follow: input["follow"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -826,7 +828,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -1768,6 +1770,32 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
},
|
||||
migration: {
|
||||
v1: {
|
||||
status: (requestOptions?: RequestOptions) =>
|
||||
request<MigrationV1StatusOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/experimental/migration/v1`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
run: (requestOptions?: RequestOptions) =>
|
||||
request<MigrationV1RunOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/migration/v1`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
providers: (input?: WebsearchProvidersInput, requestOptions?: RequestOptions) =>
|
||||
request<WebsearchProvidersOutput>(
|
||||
|
||||
@@ -313,164 +313,6 @@ export type SkillInfo = {
|
||||
content: string
|
||||
}
|
||||
|
||||
export type FileDiffLegacyInfo = {
|
||||
file?: string
|
||||
patch?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type PermissionV1Action = "allow" | "deny" | "ask"
|
||||
|
||||
export type SessionV1JSONSchema = { [x: string]: any }
|
||||
|
||||
export type ProviderAuthError = { name: "ProviderAuthError"; data: { providerID: string; message: string } }
|
||||
|
||||
export type UnknownError2 = { name: "UnknownError"; data: { message: string; ref?: string | undefined } }
|
||||
|
||||
export type MessageOutputLengthError = { name: "MessageOutputLengthError"; data: {} }
|
||||
|
||||
export type MessageAbortedError = { name: "MessageAbortedError"; data: { message: string } }
|
||||
|
||||
export type StructuredOutputError = { name: "StructuredOutputError"; data: { message: string; retries: number } }
|
||||
|
||||
export type ContextOverflowError = {
|
||||
name: "ContextOverflowError"
|
||||
data: { message: string; responseBody?: string | undefined }
|
||||
}
|
||||
|
||||
export type ContentFilterError = { name: "ContentFilterError"; data: { message: string } }
|
||||
|
||||
export type APIError = {
|
||||
name: "APIError"
|
||||
data: {
|
||||
message: string
|
||||
statusCode?: number | undefined
|
||||
isRetryable: boolean
|
||||
responseHeaders?: { [x: string]: string } | undefined
|
||||
responseBody?: string | undefined
|
||||
metadata?: { [x: string]: string } | undefined
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionV1TextPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: boolean | undefined
|
||||
ignored?: boolean | undefined
|
||||
time?: { start: number; end?: number | undefined } | undefined
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
}
|
||||
|
||||
export type SessionV1SubtaskPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "subtask"
|
||||
prompt: string
|
||||
description: string
|
||||
agent: string
|
||||
model?: { providerID: string; modelID: string } | undefined
|
||||
command?: string | undefined
|
||||
}
|
||||
|
||||
export type SessionV1ReasoningPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "reasoning"
|
||||
text: string
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
time: { start: number; end?: number | undefined }
|
||||
}
|
||||
|
||||
export type SessionV1FilePartSourceText = { value: string; start: number; end: number }
|
||||
|
||||
export type SessionV1Range = { start: { line: number; character: number }; end: { line: number; character: number } }
|
||||
|
||||
export type SessionV1ToolStatePending = { status: "pending"; input: { [x: string]: any }; raw: string }
|
||||
|
||||
export type SessionV1ToolStateRunning = {
|
||||
status: "running"
|
||||
input: { [x: string]: any }
|
||||
title?: string | undefined
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
time: { start: number }
|
||||
}
|
||||
|
||||
export type SessionV1ToolStateError = {
|
||||
status: "error"
|
||||
input: { [x: string]: any }
|
||||
error: string
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
|
||||
export type SessionV1StepStartPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "step-start"
|
||||
snapshot?: string | undefined
|
||||
}
|
||||
|
||||
export type SessionV1StepFinishPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "step-finish"
|
||||
reason: string
|
||||
snapshot?: string | undefined
|
||||
cost: number
|
||||
tokens: {
|
||||
total?: number | undefined
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cache: { read: number; write: number }
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionV1SnapshotPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "snapshot"
|
||||
snapshot: string
|
||||
}
|
||||
|
||||
export type SessionV1PatchPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "patch"
|
||||
hash: string
|
||||
files: Array<string>
|
||||
}
|
||||
|
||||
export type SessionV1AgentPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "agent"
|
||||
name: string
|
||||
source?: { value: string; start: number; end: number } | undefined
|
||||
}
|
||||
|
||||
export type SessionV1CompactionPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "compaction"
|
||||
auto: boolean
|
||||
overflow?: boolean | undefined
|
||||
tail_start_id?: string | undefined
|
||||
}
|
||||
|
||||
export type PermissionReply = "once" | "always" | "reject"
|
||||
|
||||
export type Pty = {
|
||||
@@ -569,6 +411,27 @@ export type ProviderRequest = {
|
||||
|
||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.created"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
projectID: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
parentID?: string
|
||||
slug: string
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionAgentSelected = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -676,7 +539,7 @@ export type SessionInstructionsUpdated = {
|
||||
type: "session.instructions.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; delta: { [x: string]: string | "removed" } }
|
||||
data: { sessionID: string; delta: { [x: string]: string | "removed" }; text?: string }
|
||||
}
|
||||
|
||||
export type SessionSynthetic = {
|
||||
@@ -862,26 +725,6 @@ export type AgentUpdated = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type MessageRemoved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "message.removed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; messageID: string }
|
||||
}
|
||||
|
||||
export type MessagePartRemoved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "message.part.removed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; messageID: string; partID: string }
|
||||
}
|
||||
|
||||
export type SessionUsageUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1537,96 +1380,6 @@ export type PermissionAsked = {
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionV1Rule = { permission: string; pattern: string; action: PermissionV1Action }
|
||||
|
||||
export type SessionV1OutputFormat =
|
||||
| { type: "text" }
|
||||
| { type: "json_schema"; schema: SessionV1JSONSchema; retryCount?: number | undefined | undefined }
|
||||
|
||||
export type SessionV1AssistantMessage = {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "assistant"
|
||||
time: { created: number; completed?: number | undefined }
|
||||
error?:
|
||||
| ProviderAuthError
|
||||
| UnknownError2
|
||||
| MessageOutputLengthError
|
||||
| MessageAbortedError
|
||||
| StructuredOutputError
|
||||
| ContextOverflowError
|
||||
| ContentFilterError
|
||||
| APIError
|
||||
| undefined
|
||||
parentID: string
|
||||
modelID: string
|
||||
providerID: string
|
||||
mode: string
|
||||
agent: string
|
||||
path: { cwd: string; root: string }
|
||||
summary?: boolean | undefined
|
||||
cost: number
|
||||
tokens: {
|
||||
total?: number | undefined
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cache: { read: number; write: number }
|
||||
}
|
||||
structured?: any | undefined
|
||||
variant?: string | undefined
|
||||
finish?: string | undefined
|
||||
}
|
||||
|
||||
export type SessionV1RetryPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "retry"
|
||||
attempt: number
|
||||
error: APIError
|
||||
time: { created: number }
|
||||
}
|
||||
|
||||
export type SessionError = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.error"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID?: string | undefined
|
||||
error?:
|
||||
| ProviderAuthError
|
||||
| UnknownError2
|
||||
| MessageOutputLengthError
|
||||
| MessageAbortedError
|
||||
| StructuredOutputError
|
||||
| ContextOverflowError
|
||||
| ContentFilterError
|
||||
| APIError
|
||||
| undefined
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionV1FileSource = { text: SessionV1FilePartSourceText; type: "file"; path: string }
|
||||
|
||||
export type SessionV1ResourceSource = {
|
||||
text: SessionV1FilePartSourceText
|
||||
type: "resource"
|
||||
clientName: string
|
||||
uri: string
|
||||
}
|
||||
|
||||
export type SessionV1SymbolSource = {
|
||||
text: SessionV1FilePartSourceText
|
||||
type: "symbol"
|
||||
path: string
|
||||
range: SessionV1Range
|
||||
name: string
|
||||
kind: number
|
||||
}
|
||||
|
||||
export type PermissionReplied = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1904,23 +1657,6 @@ export type FormReplied = {
|
||||
data: { id: string; sessionID: string; answer: FormAnswer }
|
||||
}
|
||||
|
||||
export type PermissionV1Ruleset = Array<PermissionV1Rule>
|
||||
|
||||
export type SessionV1UserMessage = {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "user"
|
||||
time: { created: number }
|
||||
format?: SessionV1OutputFormat | undefined
|
||||
summary?: { title?: string | undefined; body?: string | undefined; diffs: Array<FileDiffLegacyInfo> } | undefined
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string | undefined }
|
||||
system?: string | undefined
|
||||
tools?: { [x: string]: boolean } | undefined
|
||||
}
|
||||
|
||||
export type SessionV1FilePartSource = SessionV1FileSource | SessionV1SymbolSource | SessionV1ResourceSource
|
||||
|
||||
export type QuestionAsked = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1998,41 +1734,6 @@ export type IntegrationMethod =
|
||||
|
||||
export type FormFields = [FormField, ...Array<FormField>]
|
||||
|
||||
export type SessionV1Info = {
|
||||
id: string
|
||||
slug: string
|
||||
projectID: string
|
||||
workspaceID?: string
|
||||
directory: string
|
||||
path?: string
|
||||
parentID?: string
|
||||
summary?: { additions: number; deletions: number; files: number; diffs?: Array<FileDiffLegacyInfo> }
|
||||
cost?: number
|
||||
tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
|
||||
share?: { url: string }
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
version: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number; updated: number; compacting?: number; archived?: number }
|
||||
permission?: PermissionV1Ruleset
|
||||
revert?: { messageID: string; partID?: string; snapshot?: string; diff?: string }
|
||||
}
|
||||
|
||||
export type SessionV1Message = SessionV1UserMessage | SessionV1AssistantMessage
|
||||
|
||||
export type SessionV1FilePart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "file"
|
||||
mime: string
|
||||
filename?: string | undefined
|
||||
url: string
|
||||
source?: SessionV1FilePartSource | undefined
|
||||
}
|
||||
|
||||
export type FormFields1 = [FormField1, ...Array<FormField1>]
|
||||
|
||||
export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction
|
||||
@@ -2064,56 +1765,6 @@ export type IntegrationInfo = {
|
||||
|
||||
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.created"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; info: SessionV1Info }
|
||||
}
|
||||
|
||||
export type SessionUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; info: SessionV1Info }
|
||||
}
|
||||
|
||||
export type SessionDeleted1 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.deleted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; info: SessionV1Info }
|
||||
}
|
||||
|
||||
export type MessageUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "message.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; info: SessionV1Message }
|
||||
}
|
||||
|
||||
export type SessionV1ToolStateCompleted = {
|
||||
status: "completed"
|
||||
input: { [x: string]: any }
|
||||
output: string
|
||||
title: string
|
||||
metadata: { [x: string]: any }
|
||||
time: { start: number; end: number; compacted?: number | undefined }
|
||||
attachments?: Array<SessionV1FilePart> | undefined
|
||||
}
|
||||
|
||||
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
|
||||
|
||||
export type SessionInputAdmitted = {
|
||||
@@ -2137,12 +1788,6 @@ export type SessionMessageInfo =
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
|
||||
export type SessionV1ToolState =
|
||||
| SessionV1ToolStatePending
|
||||
| SessionV1ToolStateRunning
|
||||
| SessionV1ToolStateCompleted
|
||||
| SessionV1ToolStateError
|
||||
|
||||
export type FormCreated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2153,6 +1798,7 @@ export type FormCreated = {
|
||||
}
|
||||
|
||||
export type SessionEventDurable =
|
||||
| SessionCreated
|
||||
| SessionAgentSelected
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
@@ -2197,43 +1843,6 @@ export type SessionMessagesResponse = {
|
||||
cursor: { previous?: string | null; next?: string | null }
|
||||
}
|
||||
|
||||
export type SessionV1ToolPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: SessionV1ToolState
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
}
|
||||
|
||||
export type SessionLogItem = SessionEventDurable | EventLogSynced
|
||||
|
||||
export type SessionV1Part =
|
||||
| SessionV1TextPart
|
||||
| SessionV1SubtaskPart
|
||||
| SessionV1ReasoningPart
|
||||
| SessionV1FilePart
|
||||
| SessionV1ToolPart
|
||||
| SessionV1StepStartPart
|
||||
| SessionV1StepFinishPart
|
||||
| SessionV1SnapshotPart
|
||||
| SessionV1PatchPart
|
||||
| SessionV1AgentPart
|
||||
| SessionV1RetryPart
|
||||
| SessionV1CompactionPart
|
||||
|
||||
export type MessagePartUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "message.part.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; part: SessionV1Part; time: number }
|
||||
}
|
||||
|
||||
export type V2Event =
|
||||
| ModelsDevRefreshed
|
||||
| IntegrationUpdated
|
||||
@@ -2241,12 +1850,6 @@ export type V2Event =
|
||||
| CatalogUpdated
|
||||
| AgentUpdated
|
||||
| SessionCreated
|
||||
| SessionUpdated
|
||||
| SessionDeleted1
|
||||
| MessageUpdated
|
||||
| MessageRemoved
|
||||
| MessagePartUpdated
|
||||
| MessagePartRemoved
|
||||
| SessionAgentSelected
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
@@ -2325,9 +1928,10 @@ export type V2Event =
|
||||
| VcsBranchUpdated
|
||||
| McpStatusChanged
|
||||
| McpResourcesChanged
|
||||
| SessionError
|
||||
| V2EventServerConnected
|
||||
|
||||
export type SessionLogItem = SessionEventDurable | EventLogSynced
|
||||
|
||||
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
|
||||
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
|
||||
@@ -4961,6 +4565,10 @@ export type DebugLocationEvictInput = {
|
||||
|
||||
export type DebugLocationEvictOutput = void
|
||||
|
||||
export type MigrationV1StatusOutput = { status: "required" | "running" | "completed"; completed: number; total: number }
|
||||
|
||||
export type MigrationV1RunOutput = { status: "completed" }
|
||||
|
||||
export type WebsearchProvidersInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"db": "bun drizzle-kit",
|
||||
"migration": "bun run script/migration.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"benchmark:location": "bun run script/benchmark-location.ts",
|
||||
"test": "bun test --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
|
||||
+58
-399
@@ -1,19 +1,15 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1",
|
||||
"id": "2d214a71-3b0a-48c1-a667-741952c4e188",
|
||||
"prevIds": [
|
||||
"db37a97f-9b5e-4c87-be8b-4feace35136c"
|
||||
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "workspace",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "data_migration",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "account_state",
|
||||
"entityType": "tables"
|
||||
@@ -66,14 +62,6 @@
|
||||
"name": "instruction_state",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "part",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "session_message",
|
||||
"entityType": "tables"
|
||||
@@ -83,11 +71,7 @@
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "session",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "session_share",
|
||||
"name": "session_v2",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
@@ -170,26 +154,6 @@
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "name",
|
||||
"entityType": "columns",
|
||||
"table": "data_migration"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_completed",
|
||||
"entityType": "columns",
|
||||
"table": "data_migration"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
@@ -534,7 +498,7 @@
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "created",
|
||||
"entityType": "columns",
|
||||
@@ -960,116 +924,6 @@
|
||||
"entityType": "columns",
|
||||
"table": "instruction_state"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "message_id",
|
||||
"entityType": "columns",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
@@ -1218,7 +1072,7 @@
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1228,7 +1082,7 @@
|
||||
"generated": null,
|
||||
"name": "project_id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1238,7 +1092,7 @@
|
||||
"generated": null,
|
||||
"name": "workspace_id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1248,7 +1102,7 @@
|
||||
"generated": null,
|
||||
"name": "parent_id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1258,7 +1112,7 @@
|
||||
"generated": null,
|
||||
"name": "fork_session_id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1268,7 +1122,7 @@
|
||||
"generated": null,
|
||||
"name": "fork_boundary",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1278,7 +1132,7 @@
|
||||
"generated": null,
|
||||
"name": "slug",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1288,7 +1142,7 @@
|
||||
"generated": null,
|
||||
"name": "directory",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1298,7 +1152,7 @@
|
||||
"generated": null,
|
||||
"name": "path",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1308,7 +1162,7 @@
|
||||
"generated": null,
|
||||
"name": "title",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1318,7 +1172,7 @@
|
||||
"generated": null,
|
||||
"name": "version",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1328,7 +1182,7 @@
|
||||
"generated": null,
|
||||
"name": "share_url",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1338,7 +1192,7 @@
|
||||
"generated": null,
|
||||
"name": "summary_additions",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1348,7 +1202,7 @@
|
||||
"generated": null,
|
||||
"name": "summary_deletions",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1358,7 +1212,7 @@
|
||||
"generated": null,
|
||||
"name": "summary_files",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1368,7 +1222,7 @@
|
||||
"generated": null,
|
||||
"name": "summary_diffs",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1378,7 +1232,7 @@
|
||||
"generated": null,
|
||||
"name": "metadata",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "real",
|
||||
@@ -1388,7 +1242,7 @@
|
||||
"generated": null,
|
||||
"name": "cost",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1398,7 +1252,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_input",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1408,7 +1262,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_output",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1418,7 +1272,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_reasoning",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1428,7 +1282,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_cache_read",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1438,7 +1292,7 @@
|
||||
"generated": null,
|
||||
"name": "tokens_cache_write",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1448,7 +1302,7 @@
|
||||
"generated": null,
|
||||
"name": "revert",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1458,7 +1312,7 @@
|
||||
"generated": null,
|
||||
"name": "permission",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1468,7 +1322,7 @@
|
||||
"generated": null,
|
||||
"name": "agent",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1478,7 +1332,7 @@
|
||||
"generated": null,
|
||||
"name": "model",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1488,7 +1342,7 @@
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1498,7 +1352,7 @@
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1508,7 +1362,7 @@
|
||||
"generated": null,
|
||||
"name": "time_compacting",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1518,7 +1372,7 @@
|
||||
"generated": null,
|
||||
"name": "time_archived",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1528,67 +1382,7 @@
|
||||
"generated": null,
|
||||
"name": "time_suspended",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "session_share"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "session_share"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "secret",
|
||||
"entityType": "columns",
|
||||
"table": "session_share"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "url",
|
||||
"entityType": "columns",
|
||||
"table": "session_share"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "session_share"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "session_share"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -1669,14 +1463,14 @@
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_instruction_entry_session_id_session_id_fk",
|
||||
"name": "fk_instruction_entry_session_id_session_v2_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "instruction_entry"
|
||||
},
|
||||
@@ -1684,14 +1478,14 @@
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_instruction_state_session_id_session_id_fk",
|
||||
"name": "fk_instruction_state_session_id_session_v2_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "instruction_state"
|
||||
},
|
||||
@@ -1699,44 +1493,14 @@
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_message_session_id_session_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"message_id"
|
||||
],
|
||||
"tableTo": "message",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_part_message_id_message_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_message_session_id_session_id_fk",
|
||||
"name": "fk_session_message_session_id_session_v2_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_message"
|
||||
},
|
||||
@@ -1744,14 +1508,14 @@
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_input_session_id_session_id_fk",
|
||||
"name": "fk_session_pending_session_id_session_v2_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_pending"
|
||||
},
|
||||
@@ -1766,24 +1530,9 @@
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_project_id_project_id_fk",
|
||||
"name": "fk_session_v2_project_id_project_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_share_session_id_session_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_share"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -1824,15 +1573,6 @@
|
||||
"table": "workspace",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "data_migration_pk",
|
||||
"table": "data_migration",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
@@ -1923,24 +1663,6 @@
|
||||
"table": "instruction_state",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "message_pk",
|
||||
"table": "message",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "part_pk",
|
||||
"table": "part",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
@@ -1955,7 +1677,7 @@
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_input_pk",
|
||||
"name": "session_pending_pk",
|
||||
"table": "session_pending",
|
||||
"entityType": "pks"
|
||||
},
|
||||
@@ -1964,17 +1686,8 @@
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_pk",
|
||||
"table": "session",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_share_pk",
|
||||
"table": "session_share",
|
||||
"name": "session_v2_pk",
|
||||
"table": "session_v2",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
@@ -2039,60 +1752,6 @@
|
||||
"entityType": "indexes",
|
||||
"table": "permission"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "session_id",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "time_created",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "id",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "message_session_time_created_id_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "message_id",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "id",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "part_message_id_id_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "session_id",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "part_session_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
@@ -2233,9 +1892,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_project_idx",
|
||||
"name": "session_v2_project_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -2247,9 +1906,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_workspace_idx",
|
||||
"name": "session_v2_workspace_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -2261,9 +1920,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_parent_idx",
|
||||
"name": "session_v2_parent_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -2273,11 +1932,11 @@
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"session\".\"time_suspended\" is not null",
|
||||
"where": "\"session_v2\".\"time_suspended\" is not null",
|
||||
"origin": "manual",
|
||||
"name": "session_time_suspended_idx",
|
||||
"name": "session_v2_time_suspended_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session"
|
||||
"table": "session_v2"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import path from "path"
|
||||
import { Effect, Logger } from "effect"
|
||||
import { AppNodeBuilder } from "../src/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Database } from "../src/database/database"
|
||||
import { Bus } from "../src/bus"
|
||||
import { SdkPlugins } from "../src/plugin/sdk"
|
||||
import { Location } from "../src/location"
|
||||
import { LocationServiceMap } from "../src/location-service-map"
|
||||
import { AbsolutePath } from "../src/schema"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const iterationsIndex = args.indexOf("--iterations")
|
||||
const iterations = iterationsIndex === -1 ? 10 : Number(args[iterationsIndex + 1])
|
||||
const directory = args.find((arg, index) => !arg.startsWith("--") && index !== iterationsIndex + 1) ?? process.cwd()
|
||||
|
||||
if (!Number.isInteger(iterations) || iterations < 1) {
|
||||
console.error("--iterations must be a positive integer")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(path.resolve(directory)) })
|
||||
const layer = AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]),
|
||||
)
|
||||
|
||||
const measure = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const start = performance.now()
|
||||
yield* effect
|
||||
return performance.now() - start
|
||||
})
|
||||
|
||||
const stats = (samples: ReadonlyArray<number>) => {
|
||||
const sorted = samples.toSorted((a, b) => a - b)
|
||||
const percentile = (value: number) => sorted[Math.min(Math.ceil(sorted.length * value) - 1, sorted.length - 1)]
|
||||
return {
|
||||
mean: samples.reduce((total, sample) => total + sample, 0) / samples.length,
|
||||
min: sorted[0] ?? 0,
|
||||
p50: percentile(0.5),
|
||||
p95: percentile(0.95),
|
||||
max: sorted.at(-1) ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
const print = (name: string, samples: ReadonlyArray<number>) => {
|
||||
const result = stats(samples)
|
||||
console.log(
|
||||
`${name.padEnd(12)} mean ${result.mean.toFixed(2)} ms min ${result.min.toFixed(2)} ms p50 ${result.p50.toFixed(2)} ms p95 ${result.p95.toFixed(2)} ms max ${result.max.toFixed(2)} ms`,
|
||||
)
|
||||
}
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const load = locations.contextEffect(ref).pipe(Effect.scoped)
|
||||
|
||||
const first = yield* measure(load)
|
||||
const cached = yield* Effect.forEach(Array.from({ length: iterations }), () => measure(load))
|
||||
const cold = yield* Effect.forEach(Array.from({ length: iterations }), () =>
|
||||
locations.invalidate(ref).pipe(Effect.andThen(measure(load))),
|
||||
)
|
||||
|
||||
console.log(`Location: ${ref.directory}`)
|
||||
console.log(`Iterations: ${iterations}`)
|
||||
print("first", [first])
|
||||
print("cached", cached)
|
||||
print("cold", cold)
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.provide(Logger.layer([])))
|
||||
|
||||
await Effect.runPromise(program)
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
@@ -100,9 +99,14 @@ async function drizzle(temporary: string, output: string, name?: string) {
|
||||
export default { ...config, out: ${JSON.stringify(output)} }
|
||||
`,
|
||||
)
|
||||
await $`bun drizzle-kit generate --config ${config} ${name ? ["--name", name] : []}`.cwd(
|
||||
path.join(root, "packages/core"),
|
||||
)
|
||||
const child = Bun.spawn(["bun", "drizzle-kit", "generate", "--config", config, ...(name ? ["--name", name] : [])], {
|
||||
cwd: path.join(root, "packages/core"),
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
const exit = await child.exited
|
||||
if (exit !== 0) throw new Error(`Drizzle generation failed with exit code ${exit}.`)
|
||||
}
|
||||
|
||||
async function generatedMigrations(directory: string) {
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as Bus from "./bus"
|
||||
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
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 { EventSequenceTable, EventTable } from "./event/sql"
|
||||
import { Location } from "./location"
|
||||
@@ -134,8 +134,6 @@ export interface Interface {
|
||||
readonly after?: number
|
||||
readonly follow?: boolean
|
||||
}) => 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. */
|
||||
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||
readonly project: <D extends Event.Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
||||
@@ -657,19 +655,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> =>
|
||||
Effect.sync(() => {
|
||||
listeners.push(listener)
|
||||
@@ -691,7 +676,6 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
publish,
|
||||
subscribe,
|
||||
log,
|
||||
sequences,
|
||||
listen,
|
||||
project,
|
||||
replay,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
|
||||
export const DataMigrationTable = sqliteTable("data_migration", {
|
||||
name: text().primaryKey(),
|
||||
time_completed: integer().notNull(),
|
||||
})
|
||||
+2
-19
@@ -40,24 +40,7 @@ export const migrations = (
|
||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||
import("./migration/20260622170816_reset_v2_session_state"),
|
||||
import("./migration/20260622202450_simplify_session_input"),
|
||||
import("./migration/20260702134641_add_session_context_entry"),
|
||||
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
|
||||
import("./migration/20260703181610_event_created_column"),
|
||||
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
|
||||
import("./migration/20260703200000_reset_v2_session_events"),
|
||||
import("./migration/20260705180000_rename_instructions"),
|
||||
import("./migration/20260706223930_add-session-fork"),
|
||||
import("./migration/20260707010146_durable_session_inbox"),
|
||||
import("./migration/20260707120000_migrate_prelaunch_v2_state"),
|
||||
import("./migration/20260709013000_generic_session_input"),
|
||||
import("./migration/20260709025533_drop-todo"),
|
||||
import("./migration/20260709163752_time_suspended"),
|
||||
import("./migration/20260709190621_session_pending_table"),
|
||||
import("./migration/20260710025429_instruction_sync"),
|
||||
import("./migration/20260716020354_kv"),
|
||||
import("./migration/20260722011141_delete_tool_progress_events"),
|
||||
import("./migration/20260722170000_canonical_tool_results"),
|
||||
import("./migration/20260729022634_session_fork_boundary"),
|
||||
import("./migration/20260730195856_optional_session_title"),
|
||||
import("./migration/20260804233008_loose_psylocke"),
|
||||
import("./migration/20260805200742_import_legacy_credentials"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -12,6 +12,7 @@ const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export type Migration = {
|
||||
id: string
|
||||
foreignKeys?: boolean
|
||||
up: (tx: Transaction) => Effect.Effect<void, unknown>
|
||||
}
|
||||
|
||||
@@ -21,8 +22,11 @@ export function apply(db: Database) {
|
||||
const tables = yield* db.all<{ name: string }>(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
|
||||
)
|
||||
if (tables.some((table) => table.name === "session")) return yield* applyOnly(db, migrations)
|
||||
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
|
||||
return yield* applyOnly(db, migrations)
|
||||
if (tables.length > 0) return yield* Effect.die(new Error("Database is not empty and has no session table"))
|
||||
const started = Date.now()
|
||||
yield* Effect.logInfo("database schema bootstrap started", { migrations: migrations.length })
|
||||
yield* db.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* schema.up(tx)
|
||||
@@ -36,6 +40,10 @@ export function apply(db: Database) {
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* Effect.logInfo("database schema bootstrap completed", {
|
||||
migrations: migrations.length,
|
||||
durationMs: Date.now() - started,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -68,7 +76,9 @@ export function applyOnly(db: Database, input: Migration[]) {
|
||||
|
||||
for (const migration of input) {
|
||||
if (completed.has(migration.id)) continue
|
||||
yield* db.transaction((tx) =>
|
||||
const started = Date.now()
|
||||
yield* Effect.logInfo("database migration started", { migration: migration.id })
|
||||
const apply = db.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* migration.up(tx)
|
||||
yield* tx.run(
|
||||
@@ -76,6 +86,37 @@ export function applyOnly(db: Database, input: Migration[]) {
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (migration.foreignKeys !== false) {
|
||||
yield* apply.pipe(
|
||||
Effect.tapError((error) =>
|
||||
Effect.logError("database migration failed", {
|
||||
migration: migration.id,
|
||||
durationMs: Date.now() - started,
|
||||
error,
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.logInfo("database migration completed", {
|
||||
migration: migration.id,
|
||||
durationMs: Date.now() - started,
|
||||
})
|
||||
continue
|
||||
}
|
||||
yield* db.run(sql`PRAGMA foreign_keys = OFF`)
|
||||
yield* apply.pipe(
|
||||
Effect.ensuring(db.run(sql`PRAGMA foreign_keys = ON`).pipe(Effect.orDie)),
|
||||
Effect.tapError((error) =>
|
||||
Effect.logError("database migration failed", {
|
||||
migration: migration.id,
|
||||
durationMs: Date.now() - started,
|
||||
error,
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.logInfo("database migration completed", {
|
||||
migration: migration.id,
|
||||
durationMs: Date.now() - started,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ export default {
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
|
||||
yield* tx.run(`DELETE FROM \`workspace\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260702134641_add_session_context_entry",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`session_context_entry\` (
|
||||
\`session_id\` text NOT NULL,
|
||||
\`key\` text NOT NULL,
|
||||
\`value\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
CONSTRAINT \`session_context_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
|
||||
CONSTRAINT \`fk_session_context_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260703090000_reset_v2_event_rename_sweep",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
// `created` column is added by the generated 20260703181610_event_created_column
|
||||
// migration, which runs after this wipe (NOT NULL without default is safe on the
|
||||
// emptied table).
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260703181610_event_created_column",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`event\` ADD \`created\` integer NOT NULL;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260703190000_reset_v2_shell_event_payloads",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260703200000_reset_v2_session_events",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260705180000_rename_instructions",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_context_entry\` RENAME TO \`instruction_entry\``)
|
||||
yield* tx.run(`ALTER TABLE \`session_context_epoch\` RENAME TO \`instruction_checkpoint\``)
|
||||
yield* tx.run(`
|
||||
UPDATE \`event\`
|
||||
SET \`type\` = 'session.instructions.updated.1'
|
||||
WHERE \`type\` = 'session.context.updated.1'
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260706223930_add-session-fork",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_session_id\` text;`)
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_message_id\` text;`)
|
||||
yield* tx.run(`
|
||||
UPDATE \`session\`
|
||||
SET
|
||||
\`parent_id\` = NULL,
|
||||
\`fork_session_id\` = (
|
||||
SELECT json_extract(\`event\`.\`data\`, '$.parentID')
|
||||
FROM \`event\`
|
||||
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
|
||||
AND \`event\`.\`type\` = 'session.forked'
|
||||
ORDER BY \`event\`.\`seq\`
|
||||
LIMIT 1
|
||||
),
|
||||
\`fork_message_id\` = (
|
||||
SELECT json_extract(\`event\`.\`data\`, '$.from')
|
||||
FROM \`event\`
|
||||
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
|
||||
AND \`event\`.\`type\` = 'session.forked'
|
||||
ORDER BY \`event\`.\`seq\`
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM \`event\`
|
||||
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
|
||||
AND \`event\`.\`type\` = 'session.forked'
|
||||
);
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260707010146_durable_session_inbox",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`__new_session_input\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`prompt\` text,
|
||||
\`delivery\` text,
|
||||
\`admitted_seq\` integer NOT NULL,
|
||||
\`promoted_seq\` integer,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(
|
||||
`INSERT INTO \`__new_session_input\`(\`id\`, \`session_id\`, \`type\`, \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\`) SELECT \`id\`, \`session_id\`, 'prompt', \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\` FROM \`session_input\`;`,
|
||||
)
|
||||
yield* tx.run(`DROP TABLE \`session_input\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`)
|
||||
yield* tx.run(`PRAGMA foreign_keys=ON;`)
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_input_session_pending_delivery_seq_idx\`;`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
|
||||
)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,227 +0,0 @@
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString)
|
||||
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
|
||||
|
||||
export default {
|
||||
id: "20260707120000_migrate_prelaunch_v2_state",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(
|
||||
sql`DELETE FROM session_message WHERE type = 'compaction' AND json_extract(data, '$.status') = 'queued'`,
|
||||
)
|
||||
const messages = yield* tx.all<{ id: string; type: string; data: string }>(
|
||||
sql`SELECT id, type, data FROM session_message WHERE type IN ('skill', 'shell', 'assistant', 'compaction', 'synthetic')`,
|
||||
)
|
||||
for (const row of messages) {
|
||||
const data = object(decodeJson(row.data))
|
||||
yield* tx.run(
|
||||
sql`UPDATE session_message SET data = ${JSON.stringify(messageData(row.type, data))} WHERE id = ${row.id}`,
|
||||
)
|
||||
}
|
||||
|
||||
yield* tx.run(sql`DELETE FROM event WHERE type = 'session.compaction.delta.1'`)
|
||||
const events = yield* tx.all<{ id: string; aggregateID: string; seq: number; type: string; data: string }>(sql`
|
||||
SELECT id, aggregate_id as aggregateID, seq, type, data
|
||||
FROM event
|
||||
WHERE type IN (
|
||||
'session.skill.activated.1',
|
||||
'session.skill.activated.2',
|
||||
'session.compaction.started.1',
|
||||
'session.compaction.started.2',
|
||||
'session.compaction.ended.1',
|
||||
'session.compaction.failed.1',
|
||||
'session.compaction.failed.2',
|
||||
'session.revert.staged.1',
|
||||
'session.revert.staged.2'
|
||||
)
|
||||
ORDER BY aggregate_id, seq
|
||||
`)
|
||||
const compactionReasons = new Map<string, "auto" | "manual">()
|
||||
for (const row of events) {
|
||||
const data = object(decodeJson(row.data))
|
||||
if (row.type.startsWith("session.compaction.ended.")) {
|
||||
compactionReasons.delete(row.aggregateID)
|
||||
continue
|
||||
}
|
||||
const event = eventData(row.type, data, compactionReasons.get(row.aggregateID))
|
||||
if (row.type.startsWith("session.compaction.started."))
|
||||
compactionReasons.set(row.aggregateID, event.data.reason === "auto" ? "auto" : "manual")
|
||||
if (row.type.startsWith("session.compaction.failed.")) compactionReasons.delete(row.aggregateID)
|
||||
yield* tx.run(
|
||||
sql`UPDATE event SET type = ${event.type}, data = ${JSON.stringify(event.data)} WHERE id = ${row.id}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
||||
function messageData(type: string, data: Record<string, unknown>) {
|
||||
if (type === "skill")
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
skill: data.skill ?? data.id ?? data.name,
|
||||
name: data.name,
|
||||
text: data.text,
|
||||
})
|
||||
if (type === "shell") {
|
||||
const shell = object(data.shell)
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
shellID: data.shellID ?? shell.id,
|
||||
command: data.command ?? shell.command,
|
||||
status: data.status ?? shell.status,
|
||||
exit: data.exit ?? shell.exit,
|
||||
output: data.output,
|
||||
})
|
||||
}
|
||||
if (type === "assistant")
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
agent: data.agent,
|
||||
model: data.model,
|
||||
content: Array.isArray(data.content) ? data.content.map(assistantContent) : data.content,
|
||||
snapshot: data.snapshot,
|
||||
finish: data.finish,
|
||||
cost: data.cost,
|
||||
tokens: data.tokens,
|
||||
error: data.error,
|
||||
retry: data.retry,
|
||||
})
|
||||
if (type === "compaction") {
|
||||
if (data.status === "failed")
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
status: data.status,
|
||||
reason: data.reason,
|
||||
error: data.error ?? genericCompactionError,
|
||||
})
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
status: data.status,
|
||||
reason: data.reason,
|
||||
summary: data.summary,
|
||||
recent: data.recent,
|
||||
})
|
||||
}
|
||||
if (type === "synthetic")
|
||||
return defined({ metadata: data.metadata, time: data.time, text: data.text, description: data.description })
|
||||
const { sessionID: _, ...current } = data
|
||||
return current
|
||||
}
|
||||
|
||||
function assistantContent(value: unknown) {
|
||||
const content = object(value)
|
||||
if (content.type === "text") return defined({ type: content.type, text: content.text })
|
||||
if (content.type === "reasoning")
|
||||
return defined({ type: content.type, text: content.text, state: content.state, time: content.time })
|
||||
if (content.type !== "tool") return content
|
||||
return defined({
|
||||
type: content.type,
|
||||
id: content.id,
|
||||
name: content.name,
|
||||
executed: content.executed,
|
||||
providerState: content.providerState,
|
||||
providerResultState: content.providerResultState,
|
||||
state: toolState(content.state),
|
||||
time: content.time,
|
||||
})
|
||||
}
|
||||
|
||||
function toolState(value: unknown) {
|
||||
const state = object(value)
|
||||
if (state.status === "pending" || state.status === "streaming")
|
||||
return defined({ status: "streaming", input: state.input })
|
||||
if (state.status === "running")
|
||||
return defined({ status: state.status, input: state.input, structured: state.structured, content: state.content })
|
||||
if (state.status === "completed")
|
||||
return defined({
|
||||
status: state.status,
|
||||
input: state.input,
|
||||
structured: state.structured,
|
||||
content: state.content,
|
||||
result: state.result,
|
||||
})
|
||||
if (state.status === "error")
|
||||
return defined({
|
||||
status: state.status,
|
||||
input: state.input,
|
||||
structured: state.structured,
|
||||
content: state.content,
|
||||
error: state.error,
|
||||
result: state.result,
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
function eventData(type: string, data: Record<string, unknown>, compactionReason?: "auto" | "manual") {
|
||||
if (type.startsWith("session.skill.activated."))
|
||||
return {
|
||||
type: "session.skill.activated.1",
|
||||
data: defined({ sessionID: data.sessionID, id: data.id ?? data.name, name: data.name, text: data.text }),
|
||||
}
|
||||
if (type.startsWith("session.compaction.started."))
|
||||
return {
|
||||
type: "session.compaction.started.1",
|
||||
data: defined({
|
||||
sessionID: data.sessionID,
|
||||
reason: data.reason,
|
||||
recent: data.recent ?? "",
|
||||
inputID: data.inputID,
|
||||
}),
|
||||
}
|
||||
if (type.startsWith("session.compaction.failed."))
|
||||
return {
|
||||
type: "session.compaction.failed.1",
|
||||
data: defined({
|
||||
sessionID: data.sessionID,
|
||||
reason: data.reason ?? compactionReason ?? "manual",
|
||||
error: data.error ?? genericCompactionError,
|
||||
inputID: data.inputID,
|
||||
}),
|
||||
}
|
||||
const revert = object(data.revert)
|
||||
return {
|
||||
type: "session.revert.staged.1",
|
||||
data: defined({
|
||||
sessionID: data.sessionID,
|
||||
revert: defined({
|
||||
messageID: revert.messageID,
|
||||
partID: revert.partID,
|
||||
snapshot: revert.snapshot,
|
||||
files: Array.isArray(revert.files)
|
||||
? revert.files.map((value) => {
|
||||
const file = object(value)
|
||||
return defined({
|
||||
file: file.file ?? file.path,
|
||||
patch: file.patch,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
status: file.status,
|
||||
})
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const genericCompactionError = {
|
||||
type: "compaction.failed",
|
||||
message: "Compaction failed before recording an error",
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return isObject(value) ? value : {}
|
||||
}
|
||||
|
||||
function defined(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined))
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260709013000_generic_session_input",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
|
||||
yield* tx.run(`
|
||||
DELETE FROM \`event\`
|
||||
WHERE \`type\` IN ('session.prompt.admitted.1', 'session.prompt.promoted.1')
|
||||
AND json_extract(\`data\`, '$.inputID') IN (
|
||||
SELECT \`id\` FROM \`session_input\` WHERE \`type\` = 'prompt' AND \`prompt\` IS NULL
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`__new_session_input\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
\`delivery\` text,
|
||||
\`admitted_seq\` integer NOT NULL,
|
||||
\`promoted_seq\` integer,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
INSERT INTO \`__new_session_input\`(
|
||||
\`id\`, \`session_id\`, \`type\`, \`data\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\`
|
||||
)
|
||||
SELECT
|
||||
\`id\`, \`session_id\`, CASE WHEN \`type\` = 'prompt' THEN 'user' ELSE \`type\` END,
|
||||
CASE WHEN \`type\` = 'prompt' THEN \`prompt\` ELSE '{}' END,
|
||||
\`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\`
|
||||
FROM \`session_input\`
|
||||
WHERE \`type\` != 'prompt' OR \`prompt\` IS NOT NULL;
|
||||
`)
|
||||
yield* tx.run(`DROP TABLE \`session_input\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`)
|
||||
yield* tx.run(`PRAGMA foreign_keys=ON;`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE \`type\` = 'compaction' and \`promoted_seq\` is null;`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(`
|
||||
UPDATE \`event\`
|
||||
SET
|
||||
\`type\` = 'session.input.admitted.1',
|
||||
\`data\` = json_object(
|
||||
'sessionID', json_extract(\`data\`, '$.sessionID'),
|
||||
'inputID', json_extract(\`data\`, '$.inputID'),
|
||||
'input', json_object(
|
||||
'type', 'user',
|
||||
'data', json_extract(\`data\`, '$.prompt'),
|
||||
'delivery', json_extract(\`data\`, '$.delivery')
|
||||
)
|
||||
)
|
||||
WHERE \`type\` = 'session.prompt.admitted.1';
|
||||
`)
|
||||
yield* tx.run(`
|
||||
UPDATE \`event\`
|
||||
SET \`type\` = 'session.input.promoted.1'
|
||||
WHERE \`type\` = 'session.prompt.promoted.1';
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260709025533_drop-todo",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`todo_session_idx\`;`)
|
||||
yield* tx.run(`DROP TABLE \`todo\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260709163752_time_suspended",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD \`time_suspended\` integer;`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_time_suspended_idx\` ON \`session\` (\`time_suspended\`) WHERE "session"."time_suspended" is not null;`,
|
||||
)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260709190621_session_pending_table",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
// Beta reset: session_input becomes the pending-only session_pending
|
||||
// table. Dropping the old table discards consumed ledger rows and any
|
||||
// in-flight pending work along with every historical index variant.
|
||||
yield* tx.run(`DROP TABLE \`session_input\`;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`session_pending\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
\`delivery\` text,
|
||||
\`admitted_seq\` integer NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_pending_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_pending_session_delivery_seq_idx\` ON \`session_pending\` (\`session_id\`,\`delivery\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_pending_session_compaction_idx\` ON \`session_pending\` (\`session_id\`) WHERE "session_pending"."type" = 'compaction';`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_pending_session_admitted_seq_idx\` ON \`session_pending\` (\`session_id\`,\`admitted_seq\`);`,
|
||||
)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260710025429_instruction_sync",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_seq\` integer;`)
|
||||
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`__new_instruction_entry\` (
|
||||
\`session_id\` text NOT NULL,
|
||||
\`key\` text NOT NULL,
|
||||
\`value\` text,
|
||||
\`removed\` integer DEFAULT false NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
|
||||
CONSTRAINT \`fk_instruction_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
INSERT INTO \`__new_instruction_entry\`(
|
||||
\`session_id\`, \`key\`, \`value\`, \`removed\`, \`time_created\`, \`time_updated\`
|
||||
)
|
||||
SELECT \`session_id\`, \`key\`, \`value\`, false, \`time_created\`, \`time_updated\`
|
||||
FROM \`instruction_entry\`;
|
||||
`)
|
||||
yield* tx.run(`DROP TABLE \`instruction_entry\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`__new_instruction_entry\` RENAME TO \`instruction_entry\`;`)
|
||||
yield* tx.run(`PRAGMA foreign_keys=ON;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`instruction_blob\` (
|
||||
\`hash\` text PRIMARY KEY,
|
||||
\`value\` text
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`instruction_state\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`epoch_start\` integer NOT NULL,
|
||||
\`through_seq\` integer NOT NULL,
|
||||
\`initial_values\` text NOT NULL,
|
||||
\`current_values\` text NOT NULL,
|
||||
CONSTRAINT \`fk_instruction_state_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
// Persisted System rows were exclusively pre-beta instruction prose,
|
||||
// including fork copies whose message IDs no longer match the source event.
|
||||
yield* tx.run(`DELETE FROM \`session_message\` WHERE \`type\` = 'system';`)
|
||||
yield* tx.run(`
|
||||
UPDATE \`session\`
|
||||
SET \`fork_seq\` = COALESCE(
|
||||
(
|
||||
SELECT MIN(\`seq\`) - 1
|
||||
FROM \`event\`
|
||||
WHERE \`aggregate_id\` = \`session\`.\`id\` AND \`seq\` > 0
|
||||
),
|
||||
(
|
||||
SELECT \`seq\`
|
||||
FROM \`event_sequence\`
|
||||
WHERE \`aggregate_id\` = \`session\`.\`id\`
|
||||
),
|
||||
0
|
||||
)
|
||||
WHERE \`fork_session_id\` IS NOT NULL;
|
||||
`)
|
||||
yield* tx.run(`
|
||||
UPDATE \`event\`
|
||||
SET
|
||||
\`type\` = 'session.forked.2',
|
||||
\`data\` = json_set(
|
||||
\`data\`,
|
||||
'$.parentSeq',
|
||||
COALESCE(
|
||||
(SELECT \`fork_seq\` FROM \`session\` WHERE \`id\` = \`event\`.\`aggregate_id\`),
|
||||
0
|
||||
)
|
||||
)
|
||||
WHERE \`type\` = 'session.forked.1';
|
||||
`)
|
||||
yield* tx.run(`DELETE FROM \`event\` WHERE \`type\` = 'session.instructions.updated.1';`)
|
||||
yield* tx.run(`DROP TABLE \`instruction_checkpoint\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260716020354_kv",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`kv\` (
|
||||
\`key\` text PRIMARY KEY,
|
||||
\`value\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260722011141_delete_tool_progress_events",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`event\` WHERE \`type\` = 'session.tool.progress.1';`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,123 +0,0 @@
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const isJsonObject = Schema.is(Schema.Record(Schema.String, Schema.Json))
|
||||
|
||||
const object = (value: unknown): Record<string, unknown> => (isObject(value) ? value : {})
|
||||
|
||||
const stringify = (value: unknown) => {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? String(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
const contentOf = (state: Record<string, unknown>) => (Array.isArray(state.content) ? state.content : [])
|
||||
const resultOf = (state: Record<string, unknown>) =>
|
||||
isObject(state.result) && "value" in state.result ? state.result.value : state.result
|
||||
const metadataOf = (state: Record<string, unknown>) => {
|
||||
if (isJsonObject(state.structured) && Object.keys(state.structured).length > 0)
|
||||
return { metadata: state.structured }
|
||||
return isJsonObject(state.metadata) ? { metadata: state.metadata } : {}
|
||||
}
|
||||
const completedContent = (state: Record<string, unknown>) => {
|
||||
const preserved = contentOf(state)
|
||||
if (preserved.length > 0) return preserved
|
||||
return [{ type: "text", text: stringify(Object.keys(object(state.structured)).length ? state.structured : resultOf(state)) }]
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time rewrite of projected tool rows into the canonical result shape:
|
||||
* terminal states store model content plus optional metadata; the generic
|
||||
* `structured` and `result` fields disappear. Provider-hosted result payloads
|
||||
* move into provider-owned result state so hosted continuation survives.
|
||||
* Pre-release durable event versions are intentionally left untouched.
|
||||
*/
|
||||
export default {
|
||||
id: "20260722170000_canonical_tool_results",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
// Keyset-paginated batches keep memory bounded: production databases hold
|
||||
// gigabytes of assistant rows, and materializing them all at once was
|
||||
// measured at a ~5GB RSS spike.
|
||||
let cursor = ""
|
||||
while (true) {
|
||||
const messages = yield* tx.all<{ id: string; data: string }>(
|
||||
sql`SELECT id, data FROM session_message WHERE type = 'assistant' AND id > ${cursor} ORDER BY id LIMIT 1000`,
|
||||
)
|
||||
if (messages.length === 0) break
|
||||
cursor = messages[messages.length - 1].id
|
||||
yield* rewrite(tx, messages)
|
||||
}
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
||||
function rewrite(tx: Parameters<DatabaseMigration.Migration["up"]>[0], messages: { id: string; data: string }[]) {
|
||||
return Effect.gen(function* () {
|
||||
for (const row of messages) {
|
||||
// A row that never decoded is skipped rather than failing the whole
|
||||
// migration on every startup; it was equally unreadable before.
|
||||
const decoded = decodeJson(row.data)
|
||||
if (decoded._tag === "None") {
|
||||
yield* Effect.logWarning("skipping undecodable session_message row").pipe(Effect.annotateLogs({ id: row.id }))
|
||||
continue
|
||||
}
|
||||
const data = object(decoded.value)
|
||||
if (!Array.isArray(data.content)) continue
|
||||
let changed = false
|
||||
const content = data.content.map((part) => {
|
||||
const tool = object(part)
|
||||
if (tool.type !== "tool" || !isObject(tool.state)) return part
|
||||
const state = tool.state
|
||||
if (state.status !== "completed" && state.status !== "error" && state.status !== "running") return part
|
||||
if (!("structured" in state) && !("result" in state)) return part
|
||||
changed = true
|
||||
if (state.status === "running")
|
||||
return {
|
||||
...tool,
|
||||
state: {
|
||||
status: "running",
|
||||
input: object(state.input),
|
||||
metadata: object(state.structured),
|
||||
},
|
||||
}
|
||||
// Hosted payloads are irreducible provider replay state; keep them under
|
||||
// the provider-owned result state instead of a generic result field.
|
||||
const hosted =
|
||||
tool.executed === true && isObject(state.result) && "value" in state.result
|
||||
? { providerResultState: { ...object(tool.providerResultState), result: state.result.value } }
|
||||
: {}
|
||||
const preserved = contentOf(state)
|
||||
if (state.status === "completed")
|
||||
return {
|
||||
...tool,
|
||||
...hosted,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: object(state.input),
|
||||
content: completedContent(state),
|
||||
...metadataOf(state),
|
||||
},
|
||||
}
|
||||
return {
|
||||
...tool,
|
||||
...hosted,
|
||||
state: {
|
||||
status: "error",
|
||||
input: object(state.input),
|
||||
error: state.error,
|
||||
...(preserved.length > 0 ? { content: preserved } : {}),
|
||||
...metadataOf(state),
|
||||
},
|
||||
}
|
||||
})
|
||||
if (!changed) continue
|
||||
yield* tx.run(sql`UPDATE session_message SET data = ${JSON.stringify({ ...data, content })} WHERE id = ${row.id}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260729022634_session_fork_boundary",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_boundary\` text;`)
|
||||
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_message_id\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_seq\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260730195856_optional_session_title",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session\` RENAME COLUMN \`title\` TO \`title_old\``)
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD COLUMN \`title\` text`)
|
||||
yield* tx.run(`UPDATE \`session\` SET \`title\` = \`title_old\``)
|
||||
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`title_old\``)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260804233008_loose_psylocke",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`kv\` (
|
||||
\`key\` text PRIMARY KEY,
|
||||
\`value\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`instruction_blob\` (
|
||||
\`hash\` text PRIMARY KEY,
|
||||
\`value\` text
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`instruction_entry\` (
|
||||
\`session_id\` text NOT NULL,
|
||||
\`key\` text NOT NULL,
|
||||
\`value\` text,
|
||||
\`removed\` integer DEFAULT false NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
|
||||
CONSTRAINT \`fk_instruction_entry_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`instruction_state\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`epoch_start\` integer NOT NULL,
|
||||
\`through_seq\` integer NOT NULL,
|
||||
\`initial_values\` text NOT NULL,
|
||||
\`current_values\` text NOT NULL,
|
||||
CONSTRAINT \`fk_instruction_state_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`session_pending\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
\`delivery\` text,
|
||||
\`admitted_seq\` integer NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_pending_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`session_v2\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`project_id\` text NOT NULL,
|
||||
\`workspace_id\` text,
|
||||
\`parent_id\` text,
|
||||
\`fork_session_id\` text,
|
||||
\`fork_boundary\` text,
|
||||
\`slug\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`path\` text,
|
||||
\`title\` text,
|
||||
\`version\` text NOT NULL,
|
||||
\`share_url\` text,
|
||||
\`summary_additions\` integer,
|
||||
\`summary_deletions\` integer,
|
||||
\`summary_files\` integer,
|
||||
\`summary_diffs\` text,
|
||||
\`metadata\` text,
|
||||
\`cost\` real DEFAULT 0 NOT NULL,
|
||||
\`tokens_input\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_output\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_reasoning\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_cache_read\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_cache_write\` integer DEFAULT 0 NOT NULL,
|
||||
\`revert\` text,
|
||||
\`permission\` text,
|
||||
\`agent\` text,
|
||||
\`model\` text,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`ALTER TABLE \`event\` ADD \`created\` integer DEFAULT 0 NOT NULL;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`__new_session_message\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`seq\` integer NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_session_message_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`DROP TABLE \`session_message\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`__new_session_message\` RENAME TO \`session_message\`;`)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_pending_session_delivery_seq_idx\` ON \`session_pending\` (\`session_id\`,\`delivery\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_pending_session_compaction_idx\` ON \`session_pending\` (\`session_id\`) WHERE "session_pending"."type" = 'compaction';`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_pending_session_admitted_seq_idx\` ON \`session_pending\` (\`session_id\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
|
||||
)
|
||||
yield* tx.run(`DROP TABLE \`data_migration\`;`)
|
||||
yield* tx.run(`DROP TABLE \`session_context_epoch\`;`)
|
||||
yield* tx.run(`DROP TABLE \`session_input\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -0,0 +1,102 @@
|
||||
import path from "node:path"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { NonNegativeInt } from "@opencode-ai/schema/schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const LegacyOAuth = Schema.Struct({
|
||||
type: Schema.Literal("oauth"),
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: NonNegativeInt,
|
||||
accountId: Schema.optional(Schema.String),
|
||||
enterpriseUrl: Schema.optional(Schema.String),
|
||||
})
|
||||
const LegacyKey = Schema.Struct({
|
||||
type: Schema.Literal("api"),
|
||||
key: Schema.String,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
const LegacyWellKnown = Schema.Struct({
|
||||
type: Schema.Literal("wellknown"),
|
||||
key: Schema.String,
|
||||
token: Schema.String,
|
||||
})
|
||||
const LegacyValue = Schema.Union([LegacyOAuth, LegacyKey, LegacyWellKnown])
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const decodeValue = Schema.decodeUnknownOption(LegacyValue)
|
||||
const wellKnownSourcesKey = "wellknown:sources"
|
||||
|
||||
export default {
|
||||
id: "20260805200742_import_legacy_credentials",
|
||||
up(tx) {
|
||||
return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json"))
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
||||
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
|
||||
return Effect.gen(function* () {
|
||||
const file = Bun.file(filepath)
|
||||
if (!(yield* Effect.promise(() => file.exists()))) return
|
||||
const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text())))
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
return yield* Effect.fail(new Error("Legacy credential file must contain an object"))
|
||||
}
|
||||
|
||||
const origins: string[] = []
|
||||
for (const [id, raw] of Object.entries(input)) {
|
||||
const value = Option.getOrUndefined(decodeValue(raw))
|
||||
if (!value) continue
|
||||
const integrationID = id.replace(/\/+$/, "")
|
||||
if (!integrationID) continue
|
||||
if (value.type === "wellknown") origins.push(integrationID)
|
||||
if (yield* tx.get(sql`SELECT id FROM credential WHERE integration_id = ${integrationID}`)) continue
|
||||
|
||||
const credential =
|
||||
value.type === "api"
|
||||
? Credential.Key.make({ type: "key", key: value.key, metadata: value.metadata })
|
||||
: value.type === "wellknown"
|
||||
? Credential.Key.make({ type: "key", key: value.token })
|
||||
: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make(methodID(integrationID)),
|
||||
refresh: value.refresh,
|
||||
access: value.access,
|
||||
expires: value.expires,
|
||||
metadata:
|
||||
value.accountId || value.enterpriseUrl
|
||||
? {
|
||||
...(value.accountId ? { accountID: value.accountId } : {}),
|
||||
...(value.enterpriseUrl ? { enterpriseUrl: value.enterpriseUrl } : {}),
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
const now = Date.now()
|
||||
yield* tx.run(sql`
|
||||
INSERT INTO credential (id, integration_id, label, value, time_created, time_updated)
|
||||
VALUES (${Credential.ID.create()}, ${integrationID}, 'default', ${JSON.stringify(credential)}, ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
if (!origins.length) return
|
||||
const stored = yield* tx.get<{ value: string }>(sql`SELECT value FROM kv WHERE key = ${wellKnownSourcesKey}`)
|
||||
const decoded = stored ? Option.getOrUndefined(decodeJson(stored.value)) : undefined
|
||||
const current = Array.isArray(decoded) ? decoded.filter((item): item is string => typeof item === "string") : []
|
||||
const value = JSON.stringify(Array.from(new Set([...current, ...origins])))
|
||||
const now = Date.now()
|
||||
yield* tx.run(sql`
|
||||
INSERT INTO kv (key, value, time_created, time_updated)
|
||||
VALUES (${wellKnownSourcesKey}, ${value}, ${now}, ${now})
|
||||
ON CONFLICT (key) DO UPDATE SET value = excluded.value, time_updated = excluded.time_updated
|
||||
`)
|
||||
})
|
||||
}
|
||||
|
||||
function methodID(integrationID: string) {
|
||||
if (integrationID === "openai") return "chatgpt-browser"
|
||||
if (["github-copilot", "opencode", "xai"].includes(integrationID)) return "device"
|
||||
return "oauth"
|
||||
}
|
||||
@@ -14,7 +14,8 @@ function isWindowsStoragePath(input: string) {
|
||||
|
||||
function absolute(input: string) {
|
||||
const result = storagePath(input)
|
||||
if (!nodePath.posix.isAbsolute(result) && !(process.platform === "win32" && isWindowsStoragePath(result))) {
|
||||
// Persisted projects and sessions can move between operating systems during migration.
|
||||
if (!nodePath.posix.isAbsolute(result) && !isWindowsStoragePath(result)) {
|
||||
throw new Error(`Path is not absolute: ${input}`)
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -17,12 +17,6 @@ export default {
|
||||
CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`data_migration\` (
|
||||
\`name\` text PRIMARY KEY,
|
||||
\`time_completed\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`account_state\` (
|
||||
\`id\` integer PRIMARY KEY,
|
||||
@@ -81,7 +75,7 @@ export default {
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`aggregate_id\` text NOT NULL,
|
||||
\`seq\` integer NOT NULL,
|
||||
\`created\` integer NOT NULL,
|
||||
\`created\` integer DEFAULT 0 NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
|
||||
@@ -148,7 +142,7 @@ export default {
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
|
||||
CONSTRAINT \`fk_instruction_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
CONSTRAINT \`fk_instruction_entry_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
@@ -158,28 +152,7 @@ export default {
|
||||
\`through_seq\` integer NOT NULL,
|
||||
\`initial_values\` text NOT NULL,
|
||||
\`current_values\` text NOT NULL,
|
||||
CONSTRAINT \`fk_instruction_state_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`message\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`part\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`message_id\` text NOT NULL,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_part_message_id_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`message\`(\`id\`) ON DELETE CASCADE
|
||||
CONSTRAINT \`fk_instruction_state_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
@@ -191,7 +164,7 @@ export default {
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`data\` text NOT NULL,
|
||||
CONSTRAINT \`fk_session_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
CONSTRAINT \`fk_session_message_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
@@ -203,11 +176,11 @@ export default {
|
||||
\`delivery\` text,
|
||||
\`admitted_seq\` integer NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_pending_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
CONSTRAINT \`fk_session_pending_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`session\` (
|
||||
CREATE TABLE \`session_v2\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`project_id\` text NOT NULL,
|
||||
\`workspace_id\` text,
|
||||
@@ -240,18 +213,7 @@ export default {
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
CONSTRAINT \`fk_session_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`session_share\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`id\` text NOT NULL,
|
||||
\`secret\` text NOT NULL,
|
||||
\`url\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_share_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
|
||||
@@ -259,11 +221,6 @@ export default {
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
|
||||
)
|
||||
@@ -283,11 +240,11 @@ export default {
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_pending_session_admitted_seq_idx\` ON \`session_pending\` (\`session_id\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_time_suspended_idx\` ON \`session\` (\`time_suspended\`) WHERE "session"."time_suspended" is not null;`,
|
||||
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -0,0 +1,903 @@
|
||||
export * as V1Migration from "./v1-migration"
|
||||
|
||||
import { Effect, Option, Schema, Semaphore } from "effect"
|
||||
import { Database } from "./database"
|
||||
import { SessionMessageTable, SessionTable } from "../session/sql"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { KVTable } from "../kv/sql"
|
||||
import { EventSequenceTable, EventTable } from "../event/sql"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import type { Database as SQLiteDatabase } from "bun:sqlite"
|
||||
|
||||
export type SourceMessage = {
|
||||
readonly id: string
|
||||
readonly session_id: string
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly data: string
|
||||
}
|
||||
|
||||
export type SourcePart = {
|
||||
readonly id: string
|
||||
readonly message_id: string
|
||||
readonly session_id: string
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly data: string
|
||||
}
|
||||
|
||||
export type TransformInput = {
|
||||
readonly session: typeof SessionTable.$inferSelect
|
||||
readonly messages: ReadonlyArray<SourceMessage>
|
||||
readonly parts: ReadonlyArray<SourcePart>
|
||||
}
|
||||
|
||||
export type Warning = {
|
||||
readonly reason: string
|
||||
readonly sessionID: string
|
||||
readonly messageID?: string
|
||||
readonly partID?: string
|
||||
readonly observedType?: string
|
||||
}
|
||||
|
||||
export type TransformResult = {
|
||||
readonly messages: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly session_id: string
|
||||
readonly type: SessionMessage.Type
|
||||
readonly seq: number
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly data: Record<string, unknown>
|
||||
}>
|
||||
readonly session: Pick<
|
||||
typeof SessionTable.$inferInsert,
|
||||
| "agent"
|
||||
| "model"
|
||||
| "cost"
|
||||
| "tokens_input"
|
||||
| "tokens_output"
|
||||
| "tokens_reasoning"
|
||||
| "tokens_cache_read"
|
||||
| "tokens_cache_write"
|
||||
| "revert"
|
||||
| "time_compacting"
|
||||
>
|
||||
readonly watermark: number
|
||||
readonly warnings: ReadonlyArray<Warning>
|
||||
}
|
||||
|
||||
export type Status = {
|
||||
readonly status: "required" | "running" | "completed"
|
||||
readonly completed: number
|
||||
readonly total: number
|
||||
}
|
||||
|
||||
export type Result = {
|
||||
readonly status: "completed"
|
||||
}
|
||||
|
||||
type Options = {
|
||||
readonly nextDatabasePath?: string
|
||||
}
|
||||
|
||||
type NextProject = {
|
||||
readonly id: string
|
||||
readonly worktree: string
|
||||
readonly vcs: string | null
|
||||
readonly name: string | null
|
||||
readonly icon_url: string | null
|
||||
readonly icon_url_override: string | null
|
||||
readonly icon_color: string | null
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly time_initialized: number | null
|
||||
readonly sandboxes: string
|
||||
readonly commands: string | null
|
||||
}
|
||||
|
||||
type NextSession = {
|
||||
readonly id: string
|
||||
readonly project_id: string
|
||||
readonly workspace_id: string | null
|
||||
readonly parent_id: string | null
|
||||
readonly fork_session_id: string | null
|
||||
readonly fork_boundary: string | null
|
||||
readonly slug: string
|
||||
readonly directory: string
|
||||
readonly path: string | null
|
||||
readonly title: string | null
|
||||
readonly version: string
|
||||
readonly share_url: string | null
|
||||
readonly summary_additions: number | null
|
||||
readonly summary_deletions: number | null
|
||||
readonly summary_files: number | null
|
||||
readonly summary_diffs: string | null
|
||||
readonly metadata: string | null
|
||||
readonly cost: number
|
||||
readonly tokens_input: number
|
||||
readonly tokens_output: number
|
||||
readonly tokens_reasoning: number
|
||||
readonly tokens_cache_read: number
|
||||
readonly tokens_cache_write: number
|
||||
readonly revert: string | null
|
||||
readonly permission: string | null
|
||||
readonly agent: string | null
|
||||
readonly model: string | null
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly time_compacting: number | null
|
||||
readonly time_archived: number | null
|
||||
readonly time_suspended: number | null
|
||||
}
|
||||
|
||||
type NextMessage = {
|
||||
readonly id: string
|
||||
readonly session_id: string
|
||||
readonly type: string
|
||||
readonly seq: number
|
||||
readonly time_created: number
|
||||
readonly time_updated: number
|
||||
readonly data: string
|
||||
}
|
||||
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const cursorKey = "migration.v1-v2.session.cursor"
|
||||
const completedKey = "migration.v1-v2.completed"
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
|
||||
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
|
||||
let running = false
|
||||
let nextCompleted = 0
|
||||
|
||||
export function transformSession(input: TransformInput): TransformResult {
|
||||
const warnings: Warning[] = []
|
||||
const messages = input.messages
|
||||
.map((row) => {
|
||||
const value = Option.getOrUndefined(decodeJson(row.data))
|
||||
const decoded =
|
||||
value && typeof value === "object"
|
||||
? Option.getOrUndefined(decodeMessage({ ...value, id: row.id, sessionID: row.session_id }))
|
||||
: undefined
|
||||
if (decoded) return { row, value: decoded }
|
||||
warnings.push({ reason: "invalid-message", sessionID: input.session.id, messageID: row.id })
|
||||
return undefined
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== undefined)
|
||||
.sort((a, b) => a.row.time_created - b.row.time_created || a.row.id.localeCompare(b.row.id))
|
||||
const messageIDs = new Set(input.messages.map((row) => row.id))
|
||||
const parts = input.parts
|
||||
.map((row) => {
|
||||
const value = Option.getOrUndefined(decodeJson(row.data))
|
||||
const observedType = value && typeof value === "object" && "type" in value ? String(value.type) : undefined
|
||||
if (!messageIDs.has(row.message_id)) {
|
||||
warnings.push({
|
||||
reason: "orphan-part",
|
||||
sessionID: input.session.id,
|
||||
messageID: row.message_id,
|
||||
partID: row.id,
|
||||
observedType,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
const decoded =
|
||||
value && typeof value === "object"
|
||||
? Option.getOrUndefined(
|
||||
decodePart({ ...value, id: row.id, messageID: row.message_id, sessionID: row.session_id }),
|
||||
)
|
||||
: undefined
|
||||
if (decoded) return { row, value: decoded }
|
||||
warnings.push({
|
||||
reason: "invalid-part",
|
||||
sessionID: input.session.id,
|
||||
messageID: row.message_id,
|
||||
partID: row.id,
|
||||
observedType,
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== undefined)
|
||||
.sort((a, b) => a.row.id.localeCompare(b.row.id))
|
||||
const byMessage = Map.groupBy(parts, (item) => item.row.message_id)
|
||||
const paired = new Set<string>()
|
||||
const used = new Set(messages.map((item) => item.row.id))
|
||||
const projected = messages
|
||||
.flatMap((item) => {
|
||||
if (paired.has(item.row.id)) return []
|
||||
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
|
||||
if (item.value.role === "user") {
|
||||
const compaction = owned.find((part) => part.type === "compaction")
|
||||
if (compaction?.type === "compaction") {
|
||||
const pairedSummary = messages.find(
|
||||
(candidate) =>
|
||||
candidate.value.role === "assistant" &&
|
||||
candidate.value.parentID === item.row.id &&
|
||||
candidate.value.summary,
|
||||
)
|
||||
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
|
||||
paired.add(pairedSummary.row.id)
|
||||
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
|
||||
const summary = pairedSummary
|
||||
const summaryText = (byMessage.get(summary.row.id) ?? [])
|
||||
.map((part) => part.value)
|
||||
.filter((part) => part.type === "text" && part.text.length > 0)
|
||||
.map((part) => (part.type === "text" ? part.text : ""))
|
||||
.join("\n\n")
|
||||
const tailIndex = compaction.tail_start_id
|
||||
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
|
||||
: -1
|
||||
const compactionIndex = messages.findIndex((candidate) => candidate.row.id === item.row.id)
|
||||
const tail = tailIndex < 0 ? [] : messages.slice(tailIndex, compactionIndex)
|
||||
return [
|
||||
row(
|
||||
{ ...item.row, time_updated: Math.max(item.row.time_updated, summary.row.time_updated) },
|
||||
{
|
||||
id: item.row.id,
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: compaction.auto ? "auto" : "manual",
|
||||
summary: summaryText,
|
||||
recent: serializeRecent(tail, byMessage),
|
||||
time: { created: item.row.time_created },
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
const subtasks = owned.filter((part) => part.type === "subtask")
|
||||
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
|
||||
const files = owned.filter((part) => part.type === "file")
|
||||
const agents = owned.filter((part) => part.type === "agent")
|
||||
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
|
||||
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
|
||||
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
|
||||
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
|
||||
const unavailable = files.flatMap((part) =>
|
||||
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
|
||||
)
|
||||
const text = owned
|
||||
.flatMap((part) => {
|
||||
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
|
||||
if (part.type === "file" && !part.url.startsWith("data:")) return [unavailableFile(part)]
|
||||
return []
|
||||
})
|
||||
.join("\n\n")
|
||||
const agentAttachments = agents.map((part) =>
|
||||
part.type === "agent"
|
||||
? {
|
||||
name: part.name,
|
||||
...(part.source
|
||||
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
|
||||
: {}),
|
||||
}
|
||||
: { name: "" },
|
||||
)
|
||||
if (
|
||||
ordinary.length === 0 &&
|
||||
unavailable.length === 0 &&
|
||||
synthetic.length > 0 &&
|
||||
attachments.length === 0 &&
|
||||
agentAttachments.length === 0
|
||||
)
|
||||
return [
|
||||
row(item.row, {
|
||||
id: item.row.id,
|
||||
type: "synthetic",
|
||||
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
|
||||
time: { created: item.row.time_created },
|
||||
}),
|
||||
]
|
||||
const user = row(item.row, {
|
||||
id: item.row.id,
|
||||
type: "user",
|
||||
text,
|
||||
...(attachments.length ? { files: attachments } : {}),
|
||||
...(agentAttachments.length ? { agents: agentAttachments } : {}),
|
||||
time: { created: item.row.time_created },
|
||||
})
|
||||
if (synthetic.length === 0) return [user]
|
||||
return [
|
||||
user,
|
||||
row(item.row, {
|
||||
id: syntheticID(item.row.id, used),
|
||||
type: "synthetic",
|
||||
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
|
||||
time: { created: item.row.time_created },
|
||||
}),
|
||||
]
|
||||
}
|
||||
if (item.value.role !== "assistant") return []
|
||||
const assistant = item.value
|
||||
const parent = messages.find((candidate) => candidate.row.id === assistant.parentID)
|
||||
const parentParts = parent ? (byMessage.get(parent.row.id)?.map((part) => part.value) ?? []) : []
|
||||
if (
|
||||
parentParts.some((part) => part.type === "subtask") &&
|
||||
owned.some((part) => part.type === "tool" && part.tool === "task")
|
||||
)
|
||||
return []
|
||||
const content = owned.flatMap((part): Array<Record<string, unknown>> => {
|
||||
if (part.type === "text")
|
||||
return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }]
|
||||
if (part.type === "reasoning")
|
||||
return [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: part.text,
|
||||
...(part.metadata ? { state: part.metadata } : {}),
|
||||
time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) },
|
||||
},
|
||||
]
|
||||
if (part.type !== "tool") return []
|
||||
return [migrateTool(part, item.row.time_created)]
|
||||
})
|
||||
const start =
|
||||
owned.flatMap((part) => (part.type === "step-start" && part.snapshot ? [part.snapshot] : []))[0] ??
|
||||
owned.flatMap((part) => (part.type === "snapshot" ? [part.snapshot] : []))[0] ??
|
||||
owned.flatMap((part) => (part.type === "patch" ? [part.hash] : []))[0]
|
||||
const end = owned.flatMap((part) => (part.type === "step-finish" && part.snapshot ? [part.snapshot] : [])).at(-1)
|
||||
const snapshotFiles = Array.from(new Set(owned.flatMap((part) => (part.type === "patch" ? part.files : []))))
|
||||
const finish = normalizeFinish(assistant.finish)
|
||||
return [
|
||||
row(item.row, {
|
||||
id: item.row.id,
|
||||
type: "assistant",
|
||||
agent: assistant.agent,
|
||||
model: {
|
||||
providerID: assistant.providerID,
|
||||
id: assistant.modelID,
|
||||
variant: assistant.variant ?? "default",
|
||||
},
|
||||
content,
|
||||
...(start || end || snapshotFiles.length
|
||||
? {
|
||||
snapshot: {
|
||||
...(start ? { start } : {}),
|
||||
...(end ? { end } : {}),
|
||||
...(snapshotFiles.length ? { files: snapshotFiles } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(finish ? { finish } : {}),
|
||||
cost: assistant.cost,
|
||||
tokens: {
|
||||
input: assistant.tokens.input,
|
||||
output: assistant.tokens.output,
|
||||
reasoning: assistant.tokens.reasoning,
|
||||
cache: assistant.tokens.cache,
|
||||
},
|
||||
...(assistant.error ? { error: migrateError(assistant.error) } : {}),
|
||||
time: {
|
||||
created: item.row.time_created,
|
||||
...(assistant.time.completed === undefined ? {} : { completed: item.row.time_updated }),
|
||||
},
|
||||
}),
|
||||
]
|
||||
})
|
||||
.map((item, seq) => ({ ...item, seq }))
|
||||
const assistants = messages
|
||||
.filter((item) => item.value.role === "assistant")
|
||||
.map((item) => item.value)
|
||||
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
|
||||
const latestUser = messages.findLast((item) => {
|
||||
if (item.value.role !== "user") return false
|
||||
const owned = byMessage.get(item.row.id) ?? []
|
||||
if (owned.some((part) => part.value.type === "compaction")) return false
|
||||
return !owned.some((part) => part.value.type === "subtask") || !owned.every((part) => part.value.type === "subtask")
|
||||
})
|
||||
return {
|
||||
messages: projected,
|
||||
session: {
|
||||
agent: input.session.agent ?? (latestUser?.value.role === "user" ? latestUser.value.agent : null),
|
||||
model:
|
||||
input.session.model ??
|
||||
(latestUser?.value.role === "user"
|
||||
? {
|
||||
id: latestUser.value.model.modelID,
|
||||
providerID: latestUser.value.model.providerID,
|
||||
variant: latestUser.value.model.variant ?? "default",
|
||||
}
|
||||
: null),
|
||||
cost: assistants.reduce((total, item) => total + item.cost, 0),
|
||||
tokens_input: assistants.reduce((total, item) => total + item.tokens.input, 0),
|
||||
tokens_output: assistants.reduce((total, item) => total + item.tokens.output, 0),
|
||||
tokens_reasoning: assistants.reduce((total, item) => total + item.tokens.reasoning, 0),
|
||||
tokens_cache_read: assistants.reduce((total, item) => total + item.tokens.cache.read, 0),
|
||||
tokens_cache_write: assistants.reduce((total, item) => total + item.tokens.cache.write, 0),
|
||||
revert: null,
|
||||
time_compacting: null,
|
||||
},
|
||||
watermark: projected.length - 1,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
export function status(options: Options = {}): Effect.Effect<Status, never, Database.Service> {
|
||||
return Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const, completed: 0, total: 0 }
|
||||
const completed =
|
||||
(yield* db
|
||||
.select({ value: KVTable.value })
|
||||
.from(KVTable)
|
||||
.where(eq(KVTable.key, completedKey))
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
const cursor = yield* db
|
||||
.select({ value: KVTable.value })
|
||||
.from(KVTable)
|
||||
.where(eq(KVTable.key, cursorKey))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options))
|
||||
const total = legacyTotal + sourceTotal
|
||||
const cursorValue = typeof cursor?.value === "string" ? cursor.value : undefined
|
||||
const migratedLegacy =
|
||||
cursorValue !== undefined
|
||||
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursorValue}`))
|
||||
?.value ?? 0)
|
||||
: 0
|
||||
const migrated = migratedLegacy + (completed ? sourceTotal : running ? nextCompleted : 0)
|
||||
return {
|
||||
status: completed ? ("completed" as const) : running ? ("running" as const) : ("required" as const),
|
||||
completed: completed ? total : migrated,
|
||||
total,
|
||||
}
|
||||
}).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
export function run(options: Options = {}): Effect.Effect<Result, never, Database.Service> {
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
if (yield* hasKey(db, completedKey)) return { status: "completed" as const }
|
||||
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
|
||||
running = true
|
||||
const migrate = Effect.gen(function* () {
|
||||
yield* importNextDatabase(db, nextPath(options))
|
||||
while (true) {
|
||||
const cursor = yield* db
|
||||
.select({ value: KVTable.value })
|
||||
.from(KVTable)
|
||||
.where(eq(KVTable.key, cursorKey))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const cursorValue = typeof cursor?.value === "string" ? cursor.value : undefined
|
||||
const nextID = yield* db.get<{ id: string }>(
|
||||
cursorValue === undefined
|
||||
? sql`SELECT id FROM session ORDER BY id DESC LIMIT 1`
|
||||
: sql`SELECT id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
|
||||
)
|
||||
if (!nextID) break
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx.run(sql`
|
||||
INSERT OR IGNORE INTO session_v2 (
|
||||
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
||||
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
|
||||
)
|
||||
SELECT
|
||||
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
||||
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
|
||||
FROM session
|
||||
WHERE id = ${nextID.id}
|
||||
`)
|
||||
const next = yield* tx
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
|
||||
.get()
|
||||
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
|
||||
const sourceMessages = yield* tx.all<SourceMessage>(
|
||||
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const sourceParts = yield* tx.all<SourcePart>(
|
||||
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
|
||||
yield* Effect.forEach(transformed.warnings, (warning) =>
|
||||
Effect.logWarning("Skipped V1 migration row", warning),
|
||||
)
|
||||
yield* tx.delete(EventTable).where(eq(EventTable.aggregate_id, next.id)).run()
|
||||
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
|
||||
yield* Effect.forEach(transformed.messages, (message) =>
|
||||
tx.run(sql`
|
||||
INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data)
|
||||
VALUES (${message.id}, ${message.session_id}, ${message.type}, ${message.seq}, ${message.time_created}, ${message.time_updated}, ${JSON.stringify(message.data)})
|
||||
`),
|
||||
)
|
||||
yield* tx
|
||||
.update(SessionTable)
|
||||
.set({ ...transformed.session, time_updated: next.time_updated })
|
||||
.where(eq(SessionTable.id, next.id))
|
||||
.run()
|
||||
yield* tx
|
||||
.insert(EventSequenceTable)
|
||||
.values({ aggregate_id: next.id, seq: transformed.watermark })
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: transformed.watermark, owner_id: null },
|
||||
})
|
||||
.run()
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: cursorKey, value: next.id })
|
||||
.onConflictDoUpdate({ target: KVTable.key, set: { value: next.id, time_updated: Date.now() } })
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: completedKey, value: true })
|
||||
.onConflictDoUpdate({ target: KVTable.key, set: { value: true, time_updated: Date.now() } })
|
||||
.run()
|
||||
yield* tx.delete(KVTable).where(eq(KVTable.key, cursorKey)).run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
return yield* migrate.pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
running = false
|
||||
}),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
|
||||
function nextPath(options: Options) {
|
||||
if (options.nextDatabasePath) return options.nextDatabasePath
|
||||
if (process.env.OPENCODE_DB === ":memory:") return undefined
|
||||
return path.join(Global.Path.data, "opencode-next.db")
|
||||
}
|
||||
|
||||
function openNextDatabase(sourcePath: string) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
|
||||
return new sqlite.Database(sourcePath, { readonly: true, strict: true })
|
||||
}),
|
||||
(source) => Effect.sync(() => source.close()),
|
||||
)
|
||||
}
|
||||
|
||||
function countNextSessions(sourcePath: string | undefined) {
|
||||
if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0)
|
||||
return Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const source = yield* openNextDatabase(sourcePath)
|
||||
if (!isNextDatabase(source)) return 0
|
||||
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
|
||||
}),
|
||||
).pipe(Effect.orElseSucceed(() => 0))
|
||||
}
|
||||
|
||||
function importNextDatabase(
|
||||
db: Database.Interface["db"],
|
||||
sourcePath: string | undefined,
|
||||
): Effect.Effect<void, unknown> {
|
||||
if (!sourcePath || !existsSync(sourcePath)) return Effect.void
|
||||
return Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const source = yield* openNextDatabase(sourcePath)
|
||||
if (!isNextDatabase(source)) {
|
||||
yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath })
|
||||
return
|
||||
}
|
||||
source.run("BEGIN")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
if (source.inTransaction) source.run("ROLLBACK")
|
||||
}),
|
||||
)
|
||||
const projects = new Map(
|
||||
source
|
||||
.query<NextProject, []>("SELECT * FROM project")
|
||||
.all()
|
||||
.map((project) => [project.id, project]),
|
||||
)
|
||||
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
|
||||
nextCompleted = 0
|
||||
for (const session of sessions) {
|
||||
const project = projects.get(session.project_id)
|
||||
if (!project)
|
||||
return yield* Effect.die(
|
||||
new Error(`Previous V2 session ${session.id} references missing project ${session.project_id}`),
|
||||
)
|
||||
const messages = source
|
||||
.query<NextMessage, [string]>(
|
||||
"SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq",
|
||||
)
|
||||
.all(session.id)
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx.run(sql`
|
||||
INSERT OR IGNORE INTO project (
|
||||
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
|
||||
time_created, time_updated, time_initialized, sandboxes, commands
|
||||
) VALUES (
|
||||
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
|
||||
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
|
||||
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
|
||||
)
|
||||
`)
|
||||
const existing = yield* tx
|
||||
.select({ id: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make(session.id)))
|
||||
.get()
|
||||
if (existing) return
|
||||
yield* tx.run(sql`
|
||||
INSERT INTO session_v2 (
|
||||
id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, slug, directory,
|
||||
path, title, version, share_url, summary_additions, summary_deletions, summary_files,
|
||||
summary_diffs, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
|
||||
tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting,
|
||||
time_archived, time_suspended
|
||||
) VALUES (
|
||||
${session.id}, ${session.project_id}, ${session.workspace_id}, ${session.parent_id},
|
||||
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
|
||||
${session.path}, ${session.title}, ${session.version}, ${session.share_url},
|
||||
${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files},
|
||||
${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input},
|
||||
${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read},
|
||||
${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent},
|
||||
${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting},
|
||||
${session.time_archived}, ${session.time_suspended}
|
||||
)
|
||||
`)
|
||||
yield* Effect.forEach(messages, (message) =>
|
||||
tx.run(sql`
|
||||
INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data)
|
||||
VALUES (
|
||||
${message.id}, ${message.session_id}, ${message.type}, ${message.seq},
|
||||
${message.time_created}, ${message.time_updated}, ${message.data}
|
||||
)
|
||||
`),
|
||||
)
|
||||
yield* tx
|
||||
.insert(EventSequenceTable)
|
||||
.values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 })
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null },
|
||||
})
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
nextCompleted++
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
source.run("COMMIT")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function isNextDatabase(source: SQLiteDatabase) {
|
||||
const tables = new Set(
|
||||
source
|
||||
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.all()
|
||||
.map((table) => table.name),
|
||||
)
|
||||
return tables.has("project") && tables.has("session") && tables.has("session_message")
|
||||
}
|
||||
|
||||
function row(
|
||||
source: SourceMessage,
|
||||
message: {
|
||||
readonly id: string
|
||||
readonly type: SessionMessage.Type
|
||||
readonly time: { readonly created: number }
|
||||
readonly [key: string]: unknown
|
||||
},
|
||||
): TransformResult["messages"][number] {
|
||||
const { id, type, ...data } = message
|
||||
return {
|
||||
id,
|
||||
session_id: source.session_id,
|
||||
type,
|
||||
seq: 0,
|
||||
time_created: source.time_created,
|
||||
time_updated: source.time_updated,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
function migrateTool(part: typeof SessionV1.ToolPart.Type, fallback: number) {
|
||||
const base = {
|
||||
type: "tool" as const,
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
...(part.metadata ? { providerState: part.metadata } : {}),
|
||||
}
|
||||
if (part.state.status === "completed")
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: part.state.input,
|
||||
content:
|
||||
part.state.time.compacted === undefined
|
||||
? [
|
||||
{ type: "text", text: part.state.output },
|
||||
...(part.state.attachments ?? []).map((file) => ({
|
||||
type: "file" as const,
|
||||
uri: file.url,
|
||||
mime: file.mime,
|
||||
...(file.filename ? { name: file.filename } : {}),
|
||||
})),
|
||||
]
|
||||
: [{ type: "text", text: "[Old tool result content cleared]" }],
|
||||
metadata: part.state.metadata,
|
||||
},
|
||||
time: { created: part.state.time.start, completed: part.state.time.end },
|
||||
}
|
||||
if (part.state.status === "error")
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "error",
|
||||
input: part.state.input,
|
||||
error: { type: "tool.execution", message: part.state.error },
|
||||
...(typeof part.state.metadata?.output === "string"
|
||||
? { content: [{ type: "text", text: part.state.metadata.output }] }
|
||||
: {}),
|
||||
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
|
||||
},
|
||||
time: { created: part.state.time.start, completed: part.state.time.end },
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "error",
|
||||
input: part.state.input,
|
||||
error: { type: "tool.interrupted", message: "Tool execution was interrupted before V2 migration" },
|
||||
...(part.state.status === "running" && part.state.metadata ? { metadata: part.state.metadata } : {}),
|
||||
},
|
||||
time: { created: part.state.status === "running" ? part.state.time.start : fallback },
|
||||
}
|
||||
}
|
||||
|
||||
function migrateError(error: NonNullable<(typeof SessionV1.Assistant.Type)["error"]>) {
|
||||
const message =
|
||||
"message" in error.data
|
||||
? error.data.message
|
||||
: error.name === "MessageOutputLengthError"
|
||||
? "The model exceeded its output limit"
|
||||
: error.name
|
||||
const type =
|
||||
error.name === "ProviderAuthError"
|
||||
? "provider.auth"
|
||||
: error.name === "ContentFilterError"
|
||||
? "provider.content-filter"
|
||||
: error.name === "ContextOverflowError"
|
||||
? "provider.invalid-request"
|
||||
: error.name === "StructuredOutputError" || error.name === "MessageOutputLengthError"
|
||||
? "provider.invalid-output"
|
||||
: error.name === "MessageAbortedError"
|
||||
? "aborted"
|
||||
: error.name === "APIError"
|
||||
? "provider.error"
|
||||
: "unknown"
|
||||
return { type, message }
|
||||
}
|
||||
|
||||
function normalizeFinish(finish: string | undefined) {
|
||||
if (!finish) return undefined
|
||||
return (
|
||||
(["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const).find(
|
||||
(value) => value === finish,
|
||||
) ?? "unknown"
|
||||
)
|
||||
}
|
||||
|
||||
function migrateFile(part: SessionV1.FilePart) {
|
||||
if (!part.url.startsWith("data:")) return []
|
||||
const comma = part.url.indexOf(",")
|
||||
if (comma < 0) return []
|
||||
const header = part.url.slice(0, comma)
|
||||
const payload = part.url.slice(comma + 1)
|
||||
const data = header.endsWith(";base64")
|
||||
? Buffer.from(payload, "base64").toString("base64")
|
||||
: Buffer.from(decodeURIComponent(payload)).toString("base64")
|
||||
return [
|
||||
{
|
||||
data,
|
||||
mime: part.mime,
|
||||
source:
|
||||
part.source?.type === "resource" ? { type: "uri" as const, uri: part.source.uri } : { type: "inline" as const },
|
||||
...(part.filename ? { name: part.filename } : {}),
|
||||
...(part.source
|
||||
? { mention: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end } }
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function unavailableFile(part: SessionV1.FilePart) {
|
||||
const label = part.filename ?? (part.source?.type === "resource" ? part.source.uri : part.url)
|
||||
return `[Attachment unavailable after migration: ${label} (${part.mime})]`
|
||||
}
|
||||
|
||||
function syntheticID(source: string, used: Set<string>) {
|
||||
const prefix = source.slice(0, 16)
|
||||
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
for (let salt = 0; ; salt++) {
|
||||
const hex = new Bun.CryptoHasher("sha256").update(`v1-synthetic:${source}${salt ? `:${salt}` : ""}`).digest("hex")
|
||||
let value = BigInt(`0x${hex}`)
|
||||
let suffix = ""
|
||||
while (suffix.length < 14) {
|
||||
suffix = alphabet[Number(value % 62n)] + suffix
|
||||
value /= 62n
|
||||
}
|
||||
const id = prefix + suffix
|
||||
if (used.has(id)) continue
|
||||
used.add(id)
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
function serializeRecent(
|
||||
messages: ReadonlyArray<{ row: SourceMessage; value: typeof SessionV1.Info.Type }>,
|
||||
parts: Map<string, Array<{ row: SourcePart; value: typeof SessionV1.Part.Type }>>,
|
||||
) {
|
||||
return messages
|
||||
.flatMap((message) => {
|
||||
const owned = parts.get(message.row.id)?.map((part) => part.value) ?? []
|
||||
if (message.value.role === "user")
|
||||
return [
|
||||
`[User]: ${owned
|
||||
.filter((part) => part.type === "text" && !part.ignored)
|
||||
.map((part) => (part.type === "text" ? part.text : ""))
|
||||
.join("\n\n")}`,
|
||||
]
|
||||
return owned.flatMap((part) =>
|
||||
part.type === "text"
|
||||
? [`[Assistant]: ${part.text}`]
|
||||
: part.type === "reasoning" && part.text
|
||||
? [`[Assistant reasoning]: ${part.text}`]
|
||||
: [],
|
||||
)
|
||||
})
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
function hasKey(db: Database.Interface["db"], key: string) {
|
||||
return db
|
||||
.select({ key: KVTable.key })
|
||||
.from(KVTable)
|
||||
.where(eq(KVTable.key, key))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.map((row) => row !== undefined),
|
||||
Effect.orDie,
|
||||
)
|
||||
}
|
||||
|
||||
function hasLegacySessions(db: Database.Interface["db"]) {
|
||||
return db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`).pipe(
|
||||
Effect.map((row) => row !== undefined),
|
||||
Effect.orDie,
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export const EventTable = sqliteTable(
|
||||
.notNull()
|
||||
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
|
||||
seq: integer().notNull(),
|
||||
created: integer().notNull(),
|
||||
created: integer().notNull().default(0),
|
||||
type: text().notNull(),
|
||||
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
|
||||
},
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export * as FileMutation from "./file-mutation"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { dirname } from "path"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
@@ -22,22 +21,6 @@ export interface TextWriteInput {
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
export interface ConditionalWriteInput extends WriteInput {
|
||||
readonly expected: Uint8Array
|
||||
}
|
||||
|
||||
export interface RemoveInput {
|
||||
readonly target: Target
|
||||
}
|
||||
|
||||
export class StaleContentError extends Schema.TaggedErrorClass<StaleContentError>()("FileMutation.StaleContentError", {
|
||||
path: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class TargetExistsError extends Schema.TaggedErrorClass<TargetExistsError>()("FileMutation.TargetExistsError", {
|
||||
path: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface WriteResult {
|
||||
readonly operation: "write"
|
||||
readonly target: string
|
||||
@@ -45,24 +28,10 @@ export interface WriteResult {
|
||||
readonly existed: boolean
|
||||
}
|
||||
|
||||
export interface RemoveResult {
|
||||
readonly operation: "remove"
|
||||
readonly target: string
|
||||
readonly resource: string
|
||||
readonly existed: boolean
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Create without replacing an existing target. */
|
||||
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error>
|
||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
||||
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||
/** Commit only if an existing target still has the expected bytes. */
|
||||
readonly writeIfUnchanged: (
|
||||
input: ConditionalWriteInput,
|
||||
) => Effect.Effect<WriteResult, StaleContentError | FSUtil.Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<RemoveResult, FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||
@@ -89,13 +58,6 @@ const layer = Layer.effect(
|
||||
existed,
|
||||
})
|
||||
|
||||
const removeResult = (target: Target, existed: boolean): RemoveResult => ({
|
||||
operation: "remove",
|
||||
target: target.canonical,
|
||||
resource: target.resource,
|
||||
existed,
|
||||
})
|
||||
|
||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||
withTargetLock(input.target)(
|
||||
Effect.gen(function* () {
|
||||
@@ -122,62 +84,10 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
const create = Effect.fn("FileMutation.create")((input: WriteInput) =>
|
||||
withTargetLock(input.target)(
|
||||
Effect.gen(function* () {
|
||||
const write =
|
||||
typeof input.content === "string"
|
||||
? fs.writeFileString(input.target.canonical, input.content, { flag: "wx" })
|
||||
: fs.writeFile(input.target.canonical, input.content, { flag: "wx" })
|
||||
yield* write.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
fs.ensureDir(dirname(input.target.canonical)).pipe(Effect.andThen(write)),
|
||||
),
|
||||
Effect.catchReason("PlatformError", "AlreadyExists", () =>
|
||||
Effect.fail(new TargetExistsError({ path: input.target.canonical })),
|
||||
),
|
||||
)
|
||||
return writeResult(input.target, false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const writeIfUnchanged = Effect.fn("FileMutation.writeIfUnchanged")((input: ConditionalWriteInput) =>
|
||||
withTargetLock(input.target)(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* fs.readFile(input.target.canonical)
|
||||
if (!sameBytes(current, input.expected)) {
|
||||
return yield* new StaleContentError({ path: input.target.canonical })
|
||||
}
|
||||
yield* typeof input.content === "string"
|
||||
? fs.writeFileString(input.target.canonical, input.content)
|
||||
: fs.writeFile(input.target.canonical, input.content)
|
||||
return writeResult(input.target, true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const remove = Effect.fn("FileMutation.remove")((input: RemoveInput) =>
|
||||
withTargetLock(input.target)(
|
||||
Effect.gen(function* () {
|
||||
const existed = yield* fs.remove(input.target.canonical).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)),
|
||||
)
|
||||
return removeResult(input.target, existed)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({ create, write, writeTextPreservingBom, writeIfUnchanged, remove })
|
||||
return Service.of({ write, writeTextPreservingBom })
|
||||
}),
|
||||
)
|
||||
|
||||
function sameBytes(left: Uint8Array, right: Uint8Array) {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((byte, index) => byte === right[index])
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "./location"
|
||||
import { PositiveInt, RelativePath } from "./schema"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem"
|
||||
import { Entry, FileSystem, FindInput } from "@opencode-ai/schema/filesystem"
|
||||
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
|
||||
|
||||
export const ReadInput = Schema.Struct({
|
||||
@@ -53,8 +53,6 @@ export interface Interface {
|
||||
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||
@@ -76,8 +74,6 @@ const baseLayer = Layer.effect(
|
||||
})
|
||||
return Service.of({
|
||||
find: search.find,
|
||||
glob: search.glob,
|
||||
grep: search.grep,
|
||||
read: Effect.fn("FileSystem.read")(function* (input) {
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
|
||||
@@ -3,9 +3,6 @@ import {
|
||||
type DirItem,
|
||||
type DirSearchResult,
|
||||
type FileItem,
|
||||
type GrepCursor,
|
||||
type GrepMatch,
|
||||
type GrepResult,
|
||||
type InitOptions,
|
||||
type MixedItem,
|
||||
type MixedSearchResult,
|
||||
@@ -45,19 +42,6 @@ export interface MixedSearch {
|
||||
export type File = FileItem
|
||||
export type Directory = DirItem
|
||||
export type Mixed = MixedItem
|
||||
export type Cursor = GrepCursor | null
|
||||
export type Hit = GrepMatch
|
||||
|
||||
export interface Grep {
|
||||
items: GrepResult["items"]
|
||||
totalMatched: number
|
||||
totalFilesSearched: number
|
||||
totalFiles: number
|
||||
filteredFileCount: number
|
||||
nextCursor: Cursor
|
||||
regexFallbackError?: string
|
||||
}
|
||||
|
||||
export interface Picker {
|
||||
destroy(): void
|
||||
isScanning(): boolean
|
||||
@@ -71,14 +55,6 @@ export interface Picker {
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Search>
|
||||
glob(
|
||||
pattern: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Search>
|
||||
directorySearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
@@ -95,18 +71,6 @@ export interface Picker {
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<MixedSearch>
|
||||
grep(
|
||||
query: string,
|
||||
opts?: {
|
||||
mode?: "plain" | "regex" | "fuzzy"
|
||||
maxMatchesPerFile?: number
|
||||
timeBudgetMs?: number
|
||||
beforeContext?: number
|
||||
afterContext?: number
|
||||
cursor?: Cursor
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Grep>
|
||||
trackQuery(query: string, file: string): Result<boolean>
|
||||
getHistoricalQuery(offset: number): Result<string | null>
|
||||
}
|
||||
@@ -127,10 +91,8 @@ export function create(opts: Init): Result<Picker> {
|
||||
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
||||
refreshGitStatus: () => pick.refreshGitStatus(),
|
||||
fileSearch: (query, next) => pick.fileSearch(query, next),
|
||||
glob: (pattern, next) => pick.glob(pattern, next),
|
||||
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||
grep: (query, next) => pick.grep(query, next),
|
||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||
},
|
||||
|
||||
@@ -2,9 +2,6 @@ import type {
|
||||
DirItem,
|
||||
DirSearchResult,
|
||||
FileItem,
|
||||
GrepCursor,
|
||||
GrepMatch,
|
||||
GrepResult,
|
||||
InitOptions,
|
||||
MixedItem,
|
||||
MixedSearchResult,
|
||||
@@ -42,19 +39,6 @@ export interface MixedSearch {
|
||||
export type File = FileItem
|
||||
export type Directory = DirItem
|
||||
export type Mixed = MixedItem
|
||||
export type Cursor = GrepCursor | null
|
||||
export type Hit = GrepMatch
|
||||
|
||||
export interface Grep {
|
||||
items: GrepResult["items"]
|
||||
totalMatched: number
|
||||
totalFilesSearched: number
|
||||
totalFiles: number
|
||||
filteredFileCount: number
|
||||
nextCursor: Cursor
|
||||
regexFallbackError?: string
|
||||
}
|
||||
|
||||
export interface Picker {
|
||||
destroy(): void
|
||||
isScanning(): boolean
|
||||
@@ -68,14 +52,6 @@ export interface Picker {
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Search>
|
||||
glob(
|
||||
pattern: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Search>
|
||||
directorySearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
@@ -92,18 +68,6 @@ export interface Picker {
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<MixedSearch>
|
||||
grep(
|
||||
query: string,
|
||||
opts?: {
|
||||
mode?: "plain" | "regex" | "fuzzy"
|
||||
maxMatchesPerFile?: number
|
||||
timeBudgetMs?: number
|
||||
beforeContext?: number
|
||||
afterContext?: number
|
||||
cursor?: Cursor
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Grep>
|
||||
trackQuery(query: string, file: string): Result<boolean>
|
||||
getHistoricalQuery(offset: number): Result<string | null>
|
||||
}
|
||||
@@ -125,10 +89,8 @@ export function create(opts: Init): Result<Picker> {
|
||||
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
||||
refreshGitStatus: () => pick.refreshGitStatus(),
|
||||
fileSearch: (query, next) => pick.fileSearch(query, next),
|
||||
glob: (pattern, next) => pick.glob(pattern, next),
|
||||
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||
grep: (query, next) => pick.grep(query, next),
|
||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as LocationWatcher from "./location-watcher"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Config } from "../config"
|
||||
import { Bus } from "../bus"
|
||||
@@ -44,7 +43,7 @@ const layer = Layer.effect(
|
||||
const config = (yield* configService.entries())
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
const home = path.resolve(location.directory) === path.resolve(os.homedir())
|
||||
const home = Protected.isHome(location.directory)
|
||||
|
||||
if (!home && location.vcs) {
|
||||
const updates = yield* watcher.subscribe({
|
||||
|
||||
@@ -3,6 +3,10 @@ import path from "path"
|
||||
|
||||
const home = os.homedir()
|
||||
|
||||
export function isHome(directory: string) {
|
||||
return path.resolve(directory) === path.resolve(home)
|
||||
}
|
||||
|
||||
const DARWIN_HOME = [
|
||||
"Music",
|
||||
"Pictures",
|
||||
|
||||
@@ -6,15 +6,13 @@ import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { Fff } from "#fff"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../location"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { RelativePath } from "../schema"
|
||||
import { Protected } from "./protected"
|
||||
|
||||
export interface Interface {
|
||||
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
|
||||
readonly glob: (input: FileSystem.GlobInput) => Effect.Effect<readonly FileSystem.Entry[]>
|
||||
readonly grep: (input: FileSystem.GrepInput) => Effect.Effect<readonly FileSystem.Match[]>
|
||||
}
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
@@ -27,89 +25,35 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Fi
|
||||
export const ripgrepLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const state = {
|
||||
files: [] as string[],
|
||||
directories: [] as string[],
|
||||
}
|
||||
const files: string[] = []
|
||||
const directories = new Set<string>()
|
||||
const home = Protected.isHome(location.directory)
|
||||
yield* ripgrep
|
||||
.find({
|
||||
cwd: location.directory,
|
||||
pattern: "*",
|
||||
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
|
||||
limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
|
||||
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
|
||||
onEntry: (entry) =>
|
||||
Effect.sync(() => {
|
||||
state.files.push(entry.path)
|
||||
files.push(entry.path)
|
||||
const parts = entry.path.split("/")
|
||||
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
|
||||
state.directories = Array.from(directories)
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
|
||||
return Service.of({
|
||||
glob: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const target = path.resolve(location.directory, input.path ?? ".")
|
||||
const info = yield* fs.stat(target).pipe(Effect.orDie)
|
||||
const cwd = info.type === "File" ? path.dirname(target) : target
|
||||
return yield* ripgrep
|
||||
.glob({
|
||||
cwd,
|
||||
pattern: input.pattern,
|
||||
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) =>
|
||||
result.map((entry) =>
|
||||
FileSystem.Entry.make({
|
||||
...entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
}),
|
||||
grep: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const target = path.resolve(location.directory, input.path ?? ".")
|
||||
const info = yield* fs.stat(target).pipe(Effect.orDie)
|
||||
const cwd = info.type === "File" ? path.dirname(target) : target
|
||||
return yield* ripgrep
|
||||
.grep({
|
||||
cwd,
|
||||
pattern: input.pattern,
|
||||
file: info.type === "File" ? path.basename(target) : undefined,
|
||||
include: input.include,
|
||||
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) =>
|
||||
result.map((match) =>
|
||||
FileSystem.Match.make({
|
||||
...match,
|
||||
entry: FileSystem.Entry.make({
|
||||
...match.entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
}),
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const items =
|
||||
input.type === "file"
|
||||
? state.files
|
||||
? files
|
||||
: input.type === "directory"
|
||||
? state.directories
|
||||
: [...state.files, ...state.directories]
|
||||
? Array.from(directories)
|
||||
: [...files, ...directories]
|
||||
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
||||
const relative = item.target
|
||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||
@@ -143,55 +87,10 @@ export const fffLayer = Layer.effect(
|
||||
if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error })
|
||||
return Service.of({
|
||||
find: () => Effect.succeed([]),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
})
|
||||
}
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
|
||||
return Service.of({
|
||||
glob: (input) =>
|
||||
Effect.sync(() => {
|
||||
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
|
||||
const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
|
||||
pageIndex: 0,
|
||||
pageSize: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
|
||||
})
|
||||
if (!found.ok) throw found.error
|
||||
return found.value.items.map((item) =>
|
||||
FileSystem.Entry.make({
|
||||
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
|
||||
type: "file",
|
||||
}),
|
||||
)
|
||||
}),
|
||||
grep: (input) =>
|
||||
Effect.sync(() => {
|
||||
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
|
||||
const found = result.value.grep(
|
||||
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
|
||||
.filter((value) => value !== undefined)
|
||||
.join(" "),
|
||||
{ mode: "regex", pageSize: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, timeBudgetMs: 1_500 },
|
||||
)
|
||||
if (!found.ok) throw found.error
|
||||
return found.value.items.map((match) => {
|
||||
const bytes = Buffer.from(match.lineContent)
|
||||
return FileSystem.Match.make({
|
||||
entry: FileSystem.Entry.make({
|
||||
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
|
||||
type: "file",
|
||||
}),
|
||||
line: match.lineNumber,
|
||||
offset: match.byteOffset,
|
||||
text: match.lineContent.length > 2_000 ? match.lineContent.slice(0, 2_000) + "..." : match.lineContent,
|
||||
submatches: match.matchRanges.map(([start, end]) => ({
|
||||
text: bytes.subarray(start, end).toString("utf8"),
|
||||
start,
|
||||
end,
|
||||
})),
|
||||
})
|
||||
})
|
||||
}),
|
||||
find: (input) =>
|
||||
Effect.sync(() => {
|
||||
const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
|
||||
@@ -236,18 +135,19 @@ export const fffLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = (options?: Options) => Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
|
||||
return ripgrepLayer
|
||||
const location = yield* Location.Service
|
||||
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
|
||||
return location.vcs ? fffLayer : ripgrepLayer
|
||||
}),
|
||||
)
|
||||
export const layer = (options?: Options) =>
|
||||
Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
|
||||
return ripgrepLayer
|
||||
const location = yield* Location.Service
|
||||
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
|
||||
return location.vcs && !Protected.isHome(location.directory) ? fffLayer : ripgrepLayer
|
||||
}),
|
||||
)
|
||||
|
||||
export function configured(options?: Options) {
|
||||
return makeLocationNode({ service: Service, layer: layer(options), deps: [FSUtil.node, Location.node, Ripgrep.node] })
|
||||
return makeLocationNode({ service: Service, layer: layer(options), deps: [Location.node, Ripgrep.node] })
|
||||
}
|
||||
|
||||
export const node = configured()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as Formatter from "./formatter"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -11,16 +11,7 @@ import { Config } from "./config"
|
||||
import { Location } from "./location"
|
||||
import { make, type Info } from "./formatter/builtins"
|
||||
|
||||
export const Status = Schema.Struct({
|
||||
name: Schema.String,
|
||||
extensions: Schema.Array(Schema.String),
|
||||
enabled: Schema.Boolean,
|
||||
}).annotate({ identifier: "FormatterStatus" })
|
||||
export type Status = typeof Status.Type
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
@@ -84,23 +75,6 @@ const layer = Layer.effect(
|
||||
return result
|
||||
})
|
||||
|
||||
const init = Effect.fn("Formatter.init")(function* () {
|
||||
yield* load
|
||||
})
|
||||
|
||||
const status = Effect.fn("Formatter.status")(function* () {
|
||||
yield* load
|
||||
return yield* Effect.forEach(formatters, (formatter) =>
|
||||
command(formatter).pipe(
|
||||
Effect.map((enabled) => ({
|
||||
name: formatter.name,
|
||||
extensions: [...formatter.extensions],
|
||||
enabled: enabled !== false,
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||
yield* load
|
||||
const matching = formatters.filter((formatter) =>
|
||||
@@ -143,7 +117,7 @@ const layer = Layer.effect(
|
||||
return false
|
||||
})
|
||||
|
||||
return Service.of({ init, status, file })
|
||||
return Service.of({ file })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
+1
-224
@@ -1,8 +1,7 @@
|
||||
export * as Git from "./git"
|
||||
|
||||
import path from "path"
|
||||
import { randomUUID } from "crypto"
|
||||
import { Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AbsolutePath, RelativePath } from "./schema"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -36,9 +35,6 @@ const snapshotConfig = `[core]
|
||||
threads = true
|
||||
`
|
||||
|
||||
export const ChangeSet = Schema.String.pipe(Schema.brand("Git.ChangeSet"))
|
||||
export type ChangeSet = typeof ChangeSet.Type
|
||||
|
||||
export const TreeID = Schema.String.pipe(Schema.brand("Git.TreeID"))
|
||||
export type TreeID = typeof TreeID.Type
|
||||
|
||||
@@ -73,13 +69,6 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", {
|
||||
operation: Schema.Literals(["capture", "apply", "reset"]),
|
||||
directory: AbsolutePath,
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly repo: {
|
||||
readonly discover: (input: AbsolutePath) => Effect.Effect<Repository | undefined>
|
||||
@@ -116,20 +105,6 @@ export interface Interface {
|
||||
) => Effect.Effect<void, OperationError>
|
||||
readonly resetHard: (repository: Repository, revision: string) => Effect.Effect<void, OperationError>
|
||||
}
|
||||
readonly change: {
|
||||
readonly capture: (input: { repository: Repository; path: AbsolutePath }) => Effect.Effect<ChangeSet, PatchError>
|
||||
readonly apply: (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
changes: ChangeSet
|
||||
}) => Effect.Effect<void, PatchError>
|
||||
readonly discard: (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
index: "preserve" | "reset"
|
||||
untracked: "preserve" | "remove"
|
||||
}) => Effect.Effect<void, PatchError>
|
||||
}
|
||||
readonly worktree: {
|
||||
readonly create: (input: {
|
||||
repository: Repository
|
||||
@@ -175,17 +150,10 @@ export interface Interface {
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
||||
readonly preview: (input: {
|
||||
repository: Repository
|
||||
current: TreeID
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
context?: number
|
||||
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
||||
readonly restore: (input: {
|
||||
repository: Repository
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
}) => Effect.Effect<void, OperationError>
|
||||
readonly checkout: (input: { repository: Repository; tree: TreeID }) => Effect.Effect<void, OperationError>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,58 +625,6 @@ const layer = Layer.effect(
|
||||
return { mode: match[1], object: match[2] }
|
||||
})
|
||||
|
||||
const preview = Effect.fn("Git.tree.preview")(
|
||||
(input: {
|
||||
repository: Repository
|
||||
current: TreeID
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
context?: number
|
||||
}) =>
|
||||
locked(
|
||||
input.repository,
|
||||
Effect.gen(function* () {
|
||||
const index = path.join(input.repository.gitDirectory, `preview-${randomUUID()}.index`)
|
||||
const env = { GIT_INDEX_FILE: index }
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* repositoryOperation("diff", input.repository, ["read-tree", input.current], { env })
|
||||
yield* Effect.forEach(
|
||||
input.files,
|
||||
([file, tree]) =>
|
||||
Effect.gen(function* () {
|
||||
const source = yield* entry(input.repository, tree, file)
|
||||
if (!source) {
|
||||
yield* repositoryOperation(
|
||||
"diff",
|
||||
input.repository,
|
||||
["update-index", "--force-remove", "--", file],
|
||||
{ env },
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* repositoryOperation(
|
||||
"diff",
|
||||
input.repository,
|
||||
["update-index", "--add", "--cacheinfo", source.mode, source.object, file],
|
||||
{ env },
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const target = TreeID.make(
|
||||
(yield* repositoryOperation("diff", input.repository, ["write-tree"], { env })).text.trim(),
|
||||
)
|
||||
return yield* treeDiff({
|
||||
repository: input.repository,
|
||||
from: input.current,
|
||||
to: target,
|
||||
context: input.context,
|
||||
paths: Array.from(input.files.keys()),
|
||||
})
|
||||
}).pipe(Effect.ensuring(fs.remove(index).pipe(Effect.catch(() => Effect.void))))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const restore = Effect.fn("Git.tree.restore")(
|
||||
(input: { repository: Repository; files: ReadonlyMap<RelativePath, TreeID> }) =>
|
||||
locked(
|
||||
@@ -738,142 +654,6 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
const checkoutTree = Effect.fn("Git.tree.checkout")((input: { repository: Repository; tree: TreeID }) =>
|
||||
locked(
|
||||
input.repository,
|
||||
Effect.gen(function* () {
|
||||
yield* repositoryOperation("restore", input.repository, ["read-tree", input.tree])
|
||||
yield* repositoryOperation("restore", input.repository, ["checkout-index", "--all", "--force"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const capture = Effect.fn("Git.change.capture")(function* (input: { repository: Repository; path: AbsolutePath }) {
|
||||
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
|
||||
const tracked = yield* execute(
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(["diff", "--binary", "HEAD", "--", scope]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (tracked.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "capture",
|
||||
directory: input.path,
|
||||
message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
|
||||
})
|
||||
}
|
||||
|
||||
const untracked = yield* execute(
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (untracked.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "capture",
|
||||
directory: input.path,
|
||||
message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
|
||||
})
|
||||
}
|
||||
|
||||
const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
|
||||
execute(
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
Effect.flatMap((result) =>
|
||||
// git diff --no-index returns 1 when differences were found.
|
||||
result.exitCode === 0 || result.exitCode === 1
|
||||
? Effect.succeed(result.text)
|
||||
: Effect.fail(
|
||||
new PatchError({
|
||||
operation: "capture",
|
||||
directory: input.path,
|
||||
message:
|
||||
result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return ChangeSet.make([tracked.text, ...created].filter(Boolean).join("\n"))
|
||||
})
|
||||
|
||||
const apply = Effect.fn("Git.change.apply")(function* (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
changes: ChangeSet
|
||||
}) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
ChildProcess.make("git", ["apply", "-"], {
|
||||
cwd: input.path,
|
||||
extendEnv: true,
|
||||
stdin: Stream.make(new TextEncoder().encode(input.changes)),
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "apply", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (result.exitCode === 0) return
|
||||
return yield* new PatchError({
|
||||
operation: "apply",
|
||||
directory: input.path,
|
||||
message:
|
||||
result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
|
||||
})
|
||||
})
|
||||
|
||||
const discard = Effect.fn("Git.change.discard")(function* (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
index: "preserve" | "reset"
|
||||
untracked: "preserve" | "remove"
|
||||
}) {
|
||||
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
|
||||
const restore = yield* execute(
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(input.index === "reset" ? ["checkout", "HEAD", "--", scope] : ["checkout", "--", scope]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (restore.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory: input.path,
|
||||
message: restore.stderr.trim() || restore.text.trim() || "Failed to restore tracked changes",
|
||||
})
|
||||
}
|
||||
if (input.untracked === "preserve") return
|
||||
const clean = yield* execute(
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(["clean", "-fd", "--", scope]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (clean.exitCode === 0) return
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory: input.path,
|
||||
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
|
||||
})
|
||||
})
|
||||
|
||||
const worktreeRun = Effect.fnUntraced(function* (
|
||||
operation: "create" | "remove" | "list",
|
||||
repository: Repository,
|
||||
@@ -949,7 +729,6 @@ const layer = Layer.effect(
|
||||
remote: { get: remote },
|
||||
history: { head, branch, defaultRemoteBranch: remoteHead, rootCommits: roots },
|
||||
sync: { fetchRemotes: fetch, fetchBranch, checkoutRemoteBranch: checkout, resetHard: reset },
|
||||
change: { capture, apply, discard },
|
||||
worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList },
|
||||
index: { refresh, ignored },
|
||||
tree: {
|
||||
@@ -957,9 +736,7 @@ const layer = Layer.effect(
|
||||
write: writeTree,
|
||||
files: treeFiles,
|
||||
diff: treeDiff,
|
||||
preview,
|
||||
restore,
|
||||
checkout: checkoutTree,
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -59,16 +59,6 @@ export interface Interface {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
||||
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
|
||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
|
||||
/**
|
||||
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
|
||||
*
|
||||
* This exists while the old opencode project service and this core project
|
||||
* service work together: core resolves the ID, while the old service still owns
|
||||
* database migration and persistence. The old service should call this after it
|
||||
* finishes migrating from `resolve().previous` to `resolve().id`; once project
|
||||
* persistence moves into core, this separate bridge method can go away.
|
||||
*/
|
||||
readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
||||
@@ -268,11 +258,7 @@ const layer = Layer.effect(
|
||||
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
||||
})
|
||||
|
||||
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
|
||||
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
return Service.of({ list, directories, resolve, commit })
|
||||
return Service.of({ list, directories, resolve })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -31,14 +31,6 @@ export type EnsureInput = {
|
||||
readonly branch?: string
|
||||
}
|
||||
|
||||
export class InvalidRepositoryError extends Schema.TaggedErrorClass<InvalidRepositoryError>()(
|
||||
"RepositoryCacheInvalidRepositoryError",
|
||||
{
|
||||
repository: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
|
||||
"RepositoryCacheInvalidBranchError",
|
||||
{
|
||||
@@ -86,7 +78,6 @@ export class CacheOperationError extends Schema.TaggedErrorClass<CacheOperationE
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| InvalidRepositoryError
|
||||
| InvalidBranchError
|
||||
| CloneFailedError
|
||||
| FetchFailedError
|
||||
@@ -103,7 +94,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Re
|
||||
|
||||
export function isError(error: unknown): error is Error {
|
||||
return (
|
||||
error instanceof InvalidRepositoryError ||
|
||||
error instanceof InvalidBranchError ||
|
||||
error instanceof CloneFailedError ||
|
||||
error instanceof FetchFailedError ||
|
||||
@@ -114,13 +104,6 @@ export function isError(error: unknown): error is Error {
|
||||
)
|
||||
}
|
||||
|
||||
export const parseRemote = Effect.fn("RepositoryCache.parseRemote")(function* (repository: string) {
|
||||
return yield* Effect.try({
|
||||
try: () => Repository.parseRemote(repository),
|
||||
catch: (error) => new InvalidRepositoryError({ repository, message: errorMessage(error) }),
|
||||
})
|
||||
})
|
||||
|
||||
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
|
||||
return yield* Effect.try({
|
||||
try: () => Repository.validateBranch(branch),
|
||||
|
||||
@@ -44,16 +44,6 @@ export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchErr
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export type Error = InvalidReferenceError | UnsupportedLocalRepositoryError | InvalidBranchError
|
||||
|
||||
export function isError(error: unknown): error is Error {
|
||||
return (
|
||||
error instanceof InvalidReferenceError ||
|
||||
error instanceof UnsupportedLocalRepositoryError ||
|
||||
error instanceof InvalidBranchError
|
||||
)
|
||||
}
|
||||
|
||||
export function parse(input: string): Reference | undefined {
|
||||
const cleaned = normalizeInput(input)
|
||||
if (!cleaned) return
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface FindInput {
|
||||
readonly cwd: string
|
||||
readonly pattern: string
|
||||
readonly limit: number
|
||||
readonly exclude?: readonly string[]
|
||||
readonly hidden?: boolean
|
||||
readonly follow?: boolean
|
||||
readonly signal?: AbortSignal
|
||||
@@ -195,6 +196,7 @@ const layer = Layer.effect(
|
||||
...(input.hidden ? ["--hidden"] : []),
|
||||
...(input.follow ? ["--follow"] : []),
|
||||
...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
|
||||
...(input.exclude ?? []).map((pattern) => `--glob=!${pattern}`),
|
||||
"--glob=!**/.git/**",
|
||||
".",
|
||||
],
|
||||
|
||||
@@ -18,7 +18,6 @@ import { SessionMessageTable, SessionTable } from "./session/sql"
|
||||
import { SessionSchema } from "./session/schema"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
|
||||
import { Agent } from "./agent"
|
||||
import { SessionV1 } from "./v1/session"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { App } from "./app"
|
||||
import { Slug } from "./util/slug"
|
||||
@@ -221,14 +220,8 @@ export interface Interface {
|
||||
after?: number
|
||||
follow?: boolean
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
|
||||
readonly switchAgent: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
agent: Agent.ID
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
model: Model.Ref
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly move: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -364,45 +357,45 @@ const layer = Layer.effect(
|
||||
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
|
||||
const project = yield* projects.resolve(location.directory)
|
||||
yield* persistProject(project)
|
||||
const now = Date.now()
|
||||
const info = SessionV1.SessionInfo.make({
|
||||
id: sessionID,
|
||||
slug: Slug.create(),
|
||||
version: app.version,
|
||||
projectID: project.id,
|
||||
parentID: input.parentID,
|
||||
directory: location.directory,
|
||||
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
|
||||
workspaceID: location.workspaceID ? Workspace.ID.make(location.workspaceID) : undefined,
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
id: Model.ID.make(input.model.id),
|
||||
providerID: input.model.providerID,
|
||||
variant: input.model.variant,
|
||||
const projected = yield* bus
|
||||
.publish(
|
||||
SessionEvent.Created,
|
||||
{
|
||||
sessionID,
|
||||
slug: Slug.create(),
|
||||
version: app.version,
|
||||
projectID: project.id,
|
||||
parentID: input.parentID,
|
||||
location,
|
||||
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
id: Model.ID.make(input.model.id),
|
||||
providerID: input.model.providerID,
|
||||
variant: input.model.variant,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{ location },
|
||||
)
|
||||
.pipe(
|
||||
Effect.as({ type: "created" } as const),
|
||||
Effect.catchDefect((defect) => {
|
||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||
return Effect.die(defect)
|
||||
}
|
||||
: undefined,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: now, updated: now },
|
||||
})
|
||||
const projected = yield* bus.publish(SessionV1.Event.Created, { sessionID, info }, { location }).pipe(
|
||||
Effect.as({ type: "created" } as const),
|
||||
Effect.catchDefect((defect) => {
|
||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||
return Effect.die(defect)
|
||||
}
|
||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||
return store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||
return store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (projected.type === "existing") return projected.session
|
||||
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
@@ -410,7 +403,7 @@ const layer = Layer.effect(
|
||||
fork: Effect.fn("Session.fork")(function* (input) {
|
||||
const parent = yield* result.get(input.sessionID)
|
||||
const boundary = yield* db
|
||||
.select({ id: SessionMessageTable.id, seq: SessionMessageTable.seq })
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
@@ -429,13 +422,14 @@ const layer = Layer.effect(
|
||||
})
|
||||
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
|
||||
const sessionID = SessionSchema.ID.create()
|
||||
const instructionThrough =
|
||||
input.boundary.type === "before" ? boundary.seq - 1 : yield* Bus.latestSequence(db, parent.id)
|
||||
// The fork adopts the parent's newest instruction values rather than the
|
||||
// values in effect at the boundary; copied history may contain frozen
|
||||
// instruction-update text the initial baseline already reflects.
|
||||
yield* bus.publish(SessionEvent.Forked, {
|
||||
sessionID,
|
||||
parentID: parent.id,
|
||||
boundary: { ...input.boundary, messageID: boundary.id },
|
||||
instructions: yield* InstructionState.valuesAt(db, parent.id, instructionThrough),
|
||||
instructions: yield* InstructionState.current(db, parent.id),
|
||||
})
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
}),
|
||||
@@ -557,8 +551,7 @@ const layer = Layer.effect(
|
||||
const session = yield* result.get(input.sessionID)
|
||||
// A staged revert must be committed before admitting new input so the prompt
|
||||
// continues from the reverted boundary rather than stale post-boundary history.
|
||||
if (session.revert)
|
||||
yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
|
||||
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
|
||||
// Resolved lazily so prompt admission only boots location services when an
|
||||
// image attachment actually needs the resizer.
|
||||
const image = Image.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
@@ -738,23 +731,23 @@ const layer = Layer.effect(
|
||||
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info) return yield* new DestinationNotFoundError({ directory })
|
||||
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||
if (
|
||||
current.location.directory === directory &&
|
||||
current.location.workspaceID === input.workspaceID
|
||||
)
|
||||
return
|
||||
if (current.location.directory === directory && current.location.workspaceID === input.workspaceID) return
|
||||
const project = yield* projects.resolve(directory)
|
||||
yield* persistProject(project)
|
||||
if ((yield* execution.active).has(input.sessionID)) {
|
||||
yield* execution.interrupt(input.sessionID)
|
||||
yield* execution.awaitIdle(input.sessionID)
|
||||
}
|
||||
yield* bus.publish(SessionEvent.Moved, {
|
||||
sessionID: input.sessionID,
|
||||
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
|
||||
projectID: project.id,
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
})
|
||||
yield* bus.publish(
|
||||
SessionEvent.Moved,
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
|
||||
projectID: project.id,
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
},
|
||||
{ location: current.location },
|
||||
)
|
||||
}),
|
||||
compact: Effect.fn("Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
@@ -835,9 +828,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
),
|
||||
interrupt: Effect.fn("Session.interrupt")((sessionID) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||
),
|
||||
interrupt: Effect.fn("Session.interrupt")((sessionID) => Effect.uninterruptible(execution.interrupt(sessionID))),
|
||||
revert: {
|
||||
stage: Effect.fn("Session.revert.stage")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
@@ -936,12 +927,7 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
|
||||
.join("\n"),
|
||||
)
|
||||
: resolved.bytes
|
||||
const normalized = yield* normalizeImageAttachment(
|
||||
input,
|
||||
Buffer.from(content).toString("base64"),
|
||||
mime,
|
||||
image,
|
||||
)
|
||||
const normalized = yield* normalizeImageAttachment(input, Buffer.from(content).toString("base64"), mime, image)
|
||||
return FileAttachment.create({
|
||||
data: normalized.data,
|
||||
mime: normalized.mime,
|
||||
|
||||
@@ -80,10 +80,9 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* messageEntries(db, sessionID)
|
||||
const assembled = yield* InstructionState.assemble(db, sessionID, instructions)
|
||||
return {
|
||||
initial: assembled.initial,
|
||||
entries: [...messages, ...assembled.updates].toSorted((a, b) => a.seq - b.seq),
|
||||
initial: yield* InstructionState.initial(db, sessionID, instructions),
|
||||
entries: messages,
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -106,10 +105,9 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
|
||||
)
|
||||
const settled = unsettled === -1 ? messages : messages.slice(0, unsettled)
|
||||
const assembled = yield* InstructionState.preview(db, sessionID, instructions, observed)
|
||||
const entries = [...settled, ...assembled.updates].toSorted((a, b) => a.seq - b.seq)
|
||||
return {
|
||||
initial: assembled.initial,
|
||||
messages: entries.map((entry) => entry.message),
|
||||
messages: settled.map((entry) => entry.message),
|
||||
instructionUpdate: assembled.update,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
export * as InstructionState from "./instruction-state"
|
||||
|
||||
import { and, asc, desc, eq, gt, inArray, lte, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Option, Schema } from "effect"
|
||||
import { eq, inArray, sql } from "drizzle-orm"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import { Bus } from "../bus"
|
||||
import { EventTable } from "../event/sql"
|
||||
import type { Bus } from "../bus"
|
||||
import { Instructions } from "../instructions/index"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { InstructionBlobTable, InstructionStateTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decodeInstructionsUpdated = Schema.decodeUnknownSync(SessionEvent.InstructionsUpdated.data)
|
||||
const decodeForked = Schema.decodeUnknownSync(SessionEvent.Forked.data)
|
||||
|
||||
export interface Observation extends Instructions.Admission {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly initial: boolean
|
||||
readonly previous: Instructions.Values
|
||||
readonly current: Instructions.Values
|
||||
}
|
||||
|
||||
@@ -28,13 +23,14 @@ export const observe = Effect.fn("InstructionState.observe")(function* (
|
||||
instructions: Instructions.Instructions,
|
||||
sessionID: SessionSchema.ID,
|
||||
): Effect.fn.Return<Observation, Instructions.InitializationBlocked> {
|
||||
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], {
|
||||
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), find(db, sessionID)], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const result = yield* observeAgainst(observed, stored?.current_values)
|
||||
return {
|
||||
sessionID,
|
||||
initial: !stored,
|
||||
previous: stored?.current_values ?? {},
|
||||
...result,
|
||||
}
|
||||
})
|
||||
@@ -42,12 +38,20 @@ export const observe = Effect.fn("InstructionState.observe")(function* (
|
||||
export const commit = Effect.fn("InstructionState.commit")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
instructions: Instructions.Instructions,
|
||||
observation: Observation,
|
||||
) {
|
||||
if (!observation.initial && Object.keys(observation.delta).length === 0) return
|
||||
// The rendered text is frozen into the durable event: replaying it later would
|
||||
// require the Location-scoped registry that produced it.
|
||||
const text = observation.initial ? "" : yield* renderUpdateText(db, instructions, observation)
|
||||
yield* bus.publish(
|
||||
SessionEvent.InstructionsUpdated,
|
||||
{ sessionID: observation.sessionID, delta: observation.delta },
|
||||
{
|
||||
sessionID: observation.sessionID,
|
||||
delta: observation.delta,
|
||||
...(text.length > 0 ? { text } : {}),
|
||||
},
|
||||
{
|
||||
// Initial sync establishes the baseline; unlike later deltas it is not chronological history.
|
||||
...(observation.initial ? { metadata: { instructions: { initial: true } } } : {}),
|
||||
@@ -56,13 +60,27 @@ export const commit = Effect.fn("InstructionState.commit")(function* (
|
||||
)
|
||||
})
|
||||
|
||||
const renderUpdateText = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
instructions: Instructions.Instructions,
|
||||
observation: Observation,
|
||||
) {
|
||||
const replaced = Object.entries(observation.previous).filter(([key]) => Object.hasOwn(observation.delta, key))
|
||||
const blobs = yield* loadBlobs(db, replaced.map(([, hash]) => hash))
|
||||
const previous = Object.fromEntries(replaced.map(([key, hash]) => [key, requireBlob(blobs, hash)]))
|
||||
const admitted = new Map(
|
||||
Object.entries(observation.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
|
||||
)
|
||||
return Instructions.renderUpdate(instructions, previous, dereferenceDelta(observation.delta, admitted))
|
||||
})
|
||||
|
||||
export const prepare = Effect.fn("InstructionState.prepare")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
instructions: Instructions.Instructions,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
yield* commit(db, bus, yield* observe(db, instructions, sessionID))
|
||||
yield* commit(db, bus, instructions, yield* observe(db, instructions, sessionID))
|
||||
})
|
||||
|
||||
export const apply = Effect.fn("InstructionState.apply")(function* (
|
||||
@@ -140,79 +158,24 @@ export const reset = Effect.fn("InstructionState.reset")(function* (db: Database
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const state = yield* stateFromEvents(db, sessionID)
|
||||
if (!state) {
|
||||
yield* reset(db, sessionID)
|
||||
return undefined
|
||||
}
|
||||
yield* db
|
||||
.insert(InstructionStateTable)
|
||||
.values(state)
|
||||
.onConflictDoUpdate({
|
||||
target: InstructionStateTable.session_id,
|
||||
set: {
|
||||
epoch_start: state.epoch_start,
|
||||
through_seq: state.through_seq,
|
||||
initial_values: state.initial_values,
|
||||
current_values: state.current_values,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return state
|
||||
})
|
||||
|
||||
const assembleState = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
instructions: Instructions.Instructions,
|
||||
state: typeof InstructionStateTable.$inferSelect,
|
||||
) {
|
||||
const rows = yield* instructionUpdatesAfter(db, sessionID, state.epoch_start)
|
||||
const updates = rows.map((row) => ({
|
||||
row,
|
||||
delta: decodeInstructionsUpdated(row.data).delta,
|
||||
}))
|
||||
const blobs = yield* loadBlobs(db, [
|
||||
...Object.values(state.initial_values),
|
||||
...updates.flatMap((update) =>
|
||||
Object.values(update.delta).filter((hash): hash is Instructions.Hash => hash !== "removed"),
|
||||
),
|
||||
])
|
||||
const valuesAtStart = dereference(state.initial_values, blobs)
|
||||
let values = valuesAtStart
|
||||
const result: Array<{ readonly seq: number; readonly message: SessionMessage.System }> = []
|
||||
for (const update of updates) {
|
||||
const delta = dereferenceDelta(update.delta, blobs)
|
||||
const text = Instructions.renderUpdate(instructions, values, delta)
|
||||
if (text.length > 0)
|
||||
result.push({
|
||||
seq: update.row.seq,
|
||||
message: SessionMessage.System.make({
|
||||
id: SessionMessage.ID.fromEvent(Event.ID.make(update.row.id)),
|
||||
type: "system",
|
||||
text,
|
||||
time: { created: DateTime.makeUnsafe(update.row.created) },
|
||||
}),
|
||||
})
|
||||
values = Instructions.applyDelta(values, delta)
|
||||
}
|
||||
return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result, current: values }
|
||||
})
|
||||
|
||||
export const assemble = Effect.fn("InstructionState.assemble")(function* (
|
||||
/** Renders the epoch baseline shown at the start of every model request. */
|
||||
export const initial = Effect.fn("InstructionState.initial")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
instructions: Instructions.Instructions,
|
||||
) {
|
||||
const state = yield* find(db, sessionID)
|
||||
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
|
||||
const assembled = yield* assembleState(db, sessionID, instructions, state)
|
||||
return { initial: assembled.initial, updates: assembled.updates }
|
||||
const blobs = yield* loadBlobs(db, Object.values(state.initial_values))
|
||||
return Instructions.renderInitial(instructions, dereference(state.initial_values, blobs))
|
||||
})
|
||||
|
||||
/** The current instruction values, used to seed a fork's baseline. */
|
||||
export const current = Effect.fn("InstructionState.current")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
return (yield* find(db, sessionID))?.current_values
|
||||
})
|
||||
|
||||
export const preview = Effect.fn("InstructionState.preview")(function* (
|
||||
@@ -221,20 +184,26 @@ export const preview = Effect.fn("InstructionState.preview")(function* (
|
||||
instructions: Instructions.Instructions,
|
||||
observed: Instructions.ReadResult,
|
||||
) {
|
||||
const state = yield* readState(db, sessionID)
|
||||
const state = yield* find(db, sessionID)
|
||||
const result = yield* observeAgainst(observed, state?.current_values)
|
||||
const blobs = new Map<Instructions.Hash, Schema.Json>(
|
||||
const observedBlobs = new Map<Instructions.Hash, Schema.Json>(
|
||||
Object.entries(result.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
|
||||
)
|
||||
if (!state) {
|
||||
const values = dereference(result.current, blobs)
|
||||
return { initial: Instructions.renderInitial(instructions, values), updates: [], update: "" }
|
||||
const values = dereference(result.current, observedBlobs)
|
||||
return { initial: Instructions.renderInitial(instructions, values), update: "" }
|
||||
}
|
||||
const assembled = yield* assembleState(db, sessionID, instructions, state)
|
||||
const stored = yield* loadBlobs(db, [
|
||||
...Object.values(state.initial_values),
|
||||
...Object.values(state.current_values),
|
||||
])
|
||||
return {
|
||||
initial: assembled.initial,
|
||||
updates: assembled.updates,
|
||||
update: Instructions.renderUpdate(instructions, assembled.current, dereferenceDelta(result.delta, blobs)),
|
||||
initial: Instructions.renderInitial(instructions, dereference(state.initial_values, stored)),
|
||||
update: Instructions.renderUpdate(
|
||||
instructions,
|
||||
dereference(state.current_values, stored),
|
||||
dereferenceDelta(result.delta, new Map([...stored, ...observedBlobs])),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -255,46 +224,6 @@ const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sessio
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const stored = yield* find(db, sessionID)
|
||||
if (!stored) return yield* rebuild(db, sessionID)
|
||||
const latest = yield* latestRelevantSequence(db, sessionID)
|
||||
if (!latest || latest.seq <= stored.through_seq) return stored
|
||||
return yield* rebuild(db, sessionID)
|
||||
})
|
||||
|
||||
const readState = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const stored = yield* find(db, sessionID)
|
||||
if (!stored) return yield* stateFromEvents(db, sessionID)
|
||||
const latest = yield* latestRelevantSequence(db, sessionID)
|
||||
if (!latest || latest.seq <= stored.through_seq) return stored
|
||||
return yield* stateFromEvents(db, sessionID)
|
||||
})
|
||||
|
||||
const stateFromEvents = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const folded = fold(yield* instructionEvents(db, sessionID))
|
||||
return folded ? foldedState(sessionID, folded) : undefined
|
||||
})
|
||||
|
||||
export const valuesAt = Effect.fn("InstructionState.valuesAt")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
through: number,
|
||||
) {
|
||||
return fold(yield* instructionEvents(db, sessionID, through))?.current
|
||||
})
|
||||
|
||||
const latestRelevantSequence = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select({ seq: EventTable.seq })
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, sessionID), inArray(EventTable.type, relevantEventTypes)))
|
||||
.orderBy(desc(EventTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly<Record<string, Schema.Json>>) {
|
||||
const rows = Object.entries(blobs).map(([hash, value]) => ({ hash: Instructions.Hash.make(hash), value }))
|
||||
if (rows.length === 0) return
|
||||
@@ -339,106 +268,3 @@ function requireBlob(blobs: ReadonlyMap<Instructions.Hash, Schema.Json>, hash: I
|
||||
if (value === undefined) throw new Error(`Instruction blob not found: ${hash}`)
|
||||
return value
|
||||
}
|
||||
|
||||
const instructionEventType = Bus.versionedType(
|
||||
SessionEvent.InstructionsUpdated.type,
|
||||
SessionEvent.InstructionsUpdated.durable.version,
|
||||
)
|
||||
const compactionEventType = Bus.versionedType(
|
||||
SessionEvent.Compaction.Ended.type,
|
||||
SessionEvent.Compaction.Ended.durable.version,
|
||||
)
|
||||
const movedEventType = Bus.versionedType(SessionEvent.Moved.type, SessionEvent.Moved.durable.version)
|
||||
const revertedEventType = Bus.versionedType(
|
||||
SessionEvent.RevertEvent.Committed.type,
|
||||
SessionEvent.RevertEvent.Committed.durable.version,
|
||||
)
|
||||
const forkedEventType = Bus.versionedType(SessionEvent.Forked.type, SessionEvent.Forked.durable.version)
|
||||
const relevantEventTypes = [
|
||||
forkedEventType,
|
||||
instructionEventType,
|
||||
compactionEventType,
|
||||
movedEventType,
|
||||
revertedEventType,
|
||||
]
|
||||
|
||||
type InstructionEventRow = typeof EventTable.$inferSelect
|
||||
|
||||
const instructionEvents = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
through?: number,
|
||||
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
|
||||
return yield* eventRows(db, sessionID, relevantEventTypes, undefined, through)
|
||||
})
|
||||
|
||||
const instructionUpdatesAfter = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
after: number,
|
||||
) {
|
||||
return yield* eventRows(db, sessionID, [instructionEventType], after)
|
||||
})
|
||||
|
||||
const eventRows = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
types: ReadonlyArray<string>,
|
||||
after?: number,
|
||||
through?: number,
|
||||
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
|
||||
return yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(
|
||||
and(
|
||||
eq(EventTable.aggregate_id, sessionID),
|
||||
inArray(EventTable.type, types),
|
||||
after === undefined ? undefined : gt(EventTable.seq, after),
|
||||
through === undefined ? undefined : lte(EventTable.seq, through),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
function fold(rows: ReadonlyArray<InstructionEventRow>) {
|
||||
return rows.reduce<
|
||||
| {
|
||||
readonly epochStart: number
|
||||
readonly throughSeq: number
|
||||
readonly initial: Instructions.Values
|
||||
readonly current: Instructions.Values
|
||||
}
|
||||
| undefined
|
||||
>((state, row) => {
|
||||
if (row.type === forkedEventType) {
|
||||
const instructions = decodeForked(row.data).instructions
|
||||
return instructions
|
||||
? { epochStart: row.seq, throughSeq: row.seq, initial: instructions, current: instructions }
|
||||
: undefined
|
||||
}
|
||||
if (row.type === movedEventType || row.type === revertedEventType) return undefined
|
||||
if (row.type === compactionEventType)
|
||||
return state
|
||||
? { epochStart: row.seq, throughSeq: row.seq, initial: state.current, current: state.current }
|
||||
: undefined
|
||||
if (row.type !== instructionEventType) return state
|
||||
const delta = decodeInstructionsUpdated(row.data).delta
|
||||
const current = Instructions.applyHashDelta(state?.current ?? {}, delta)
|
||||
return state
|
||||
? { ...state, throughSeq: row.seq, current }
|
||||
: { epochStart: row.seq, throughSeq: row.seq, initial: current, current }
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
function foldedState(sessionID: SessionSchema.ID, folded: NonNullable<ReturnType<typeof fold>>) {
|
||||
return {
|
||||
session_id: sessionID,
|
||||
epoch_start: folded.epochStart,
|
||||
through_seq: folded.throughSeq,
|
||||
initial_values: folded.initial,
|
||||
current_values: folded.current,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { castDraft, produce, type WritableDraft } from "immer"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { DateTime, Effect, Match, pipe } from "effect"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
|
||||
export type MemoryState = {
|
||||
messages: SessionMessage.Info[]
|
||||
}
|
||||
|
||||
export interface Adapter {
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
@@ -23,89 +19,7 @@ export interface Adapter {
|
||||
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
|
||||
}
|
||||
|
||||
export function memory(state: MemoryState): Adapter {
|
||||
const assistantIndex = (messageID: SessionMessage.ID) =>
|
||||
state.messages.findLastIndex((message) => message.id === messageID)
|
||||
const shellIndex = (messageID: SessionMessage.ID) =>
|
||||
state.messages.findLastIndex((message) => message.id === messageID)
|
||||
const compactionIndex = () =>
|
||||
state.messages.findLastIndex((message) => message.type === "compaction" && message.status === "running")
|
||||
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
|
||||
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
|
||||
|
||||
return {
|
||||
getModel() {
|
||||
return Effect.sync(
|
||||
() =>
|
||||
state.messages.findLast(
|
||||
(message): message is SessionMessage.ModelSelected | SessionMessage.Assistant =>
|
||||
message.type === "model-switched" || message.type === "assistant",
|
||||
)?.model,
|
||||
)
|
||||
},
|
||||
getCurrentAssistant() {
|
||||
return Effect.sync(() => {
|
||||
const index = latestAssistantIndex()
|
||||
if (index < 0) return
|
||||
const assistant = state.messages[index]
|
||||
return assistant?.type === "assistant" && !assistant.time.completed ? assistant : undefined
|
||||
})
|
||||
},
|
||||
getAssistant(messageID) {
|
||||
return Effect.sync(() => {
|
||||
const index = assistantIndex(messageID)
|
||||
if (index < 0) return
|
||||
const assistant = state.messages[index]
|
||||
return assistant?.type === "assistant" ? assistant : undefined
|
||||
})
|
||||
},
|
||||
getShell(shellID) {
|
||||
return Effect.sync(() => {
|
||||
return state.messages.find((message): message is SessionMessage.Shell => {
|
||||
return message.type === "shell" && message.shellID === shellID
|
||||
})
|
||||
})
|
||||
},
|
||||
getCompaction() {
|
||||
return Effect.sync(() => {
|
||||
const index = compactionIndex()
|
||||
const message = state.messages[index]
|
||||
return message?.type === "compaction" ? message : undefined
|
||||
})
|
||||
},
|
||||
updateAssistant(assistant) {
|
||||
return Effect.sync(() => {
|
||||
const index = assistantIndex(assistant.id)
|
||||
if (index < 0) return
|
||||
const current = state.messages[index]
|
||||
if (current?.type !== "assistant") return
|
||||
state.messages[index] = assistant
|
||||
})
|
||||
},
|
||||
updateShell(shell) {
|
||||
return Effect.sync(() => {
|
||||
const index = shellIndex(shell.id)
|
||||
if (index < 0) return
|
||||
const current = state.messages[index]
|
||||
if (current?.type !== "shell") return
|
||||
state.messages[index] = shell
|
||||
})
|
||||
},
|
||||
updateCompaction(compaction) {
|
||||
return Effect.sync(() => {
|
||||
const index = state.messages.findLastIndex((message) => message.id === compaction.id)
|
||||
if (index >= 0) state.messages[index] = compaction
|
||||
})
|
||||
},
|
||||
appendMessage(message) {
|
||||
return Effect.sync(() => {
|
||||
state.messages.push(message)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
|
||||
type DraftTool = WritableDraft<SessionMessage.AssistantTool>
|
||||
type DraftText = WritableDraft<SessionMessage.AssistantText>
|
||||
@@ -139,9 +53,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
yield* SessionEvent.All.match(event, {
|
||||
"session.usage.updated": () => Effect.void,
|
||||
const project = pipe(
|
||||
Match.type<SessionEvent.DurableEvent>(),
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
"session.created": () => Effect.void,
|
||||
"session.usage.recorded": () => Effect.void,
|
||||
"session.agent.selected": (event) => {
|
||||
return adapter.appendMessage(
|
||||
@@ -179,7 +94,18 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
"session.execution.succeeded": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
"session.execution.interrupted": () => clearCurrentRetry,
|
||||
"session.instructions.updated": () => Effect.void,
|
||||
"session.instructions.updated": (event) => {
|
||||
if (event.data.text === undefined) return Effect.void
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.System.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "system",
|
||||
text: event.data.text,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.synthetic": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.Synthetic.make({
|
||||
@@ -310,12 +236,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
|
||||
})
|
||||
},
|
||||
"session.text.delta": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestText(draft)
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
},
|
||||
"session.text.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestText(draft)
|
||||
@@ -340,7 +260,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
)
|
||||
})
|
||||
},
|
||||
"session.tool.input.delta": () => Effect.void,
|
||||
"session.tool.input.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.id)
|
||||
@@ -364,14 +283,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.tool.progress": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.id)
|
||||
if (match && match.state.status === "running") {
|
||||
match.state.metadata = event.data.metadata
|
||||
}
|
||||
})
|
||||
},
|
||||
// Terminal tool events are self-contained; projection is a direct copy and
|
||||
// never reaches into ephemeral progress history.
|
||||
"session.tool.success": (event) => {
|
||||
@@ -425,12 +336,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
)
|
||||
})
|
||||
},
|
||||
"session.reasoning.delta": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestReasoning(draft)
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
},
|
||||
"session.reasoning.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestReasoning(draft)
|
||||
@@ -464,12 +369,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
time: { created: event.created },
|
||||
}),
|
||||
),
|
||||
"session.compaction.delta": (event) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* adapter.getCompaction()
|
||||
if (current?.status !== "running") return
|
||||
yield* adapter.updateCompaction({ ...current, summary: current.summary + event.data.text })
|
||||
}),
|
||||
"session.compaction.ended": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const current = yield* adapter.getCompaction()
|
||||
@@ -515,8 +414,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
"session.revert.staged": () => Effect.void,
|
||||
"session.revert.cleared": () => Effect.void,
|
||||
"session.revert.committed": () => Effect.void,
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
return project(event)
|
||||
}
|
||||
|
||||
export * as SessionMessageUpdater from "./message-updater"
|
||||
|
||||
@@ -12,10 +12,8 @@ import {
|
||||
User,
|
||||
UserData,
|
||||
} from "@opencode-ai/schema/session-pending"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Database } from "../database/database"
|
||||
import { Bus } from "../bus"
|
||||
import { EventTable } from "../event/sql"
|
||||
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
@@ -37,11 +35,7 @@ const decodeUser = Schema.decodeUnknownSync(UserData)
|
||||
const encodeUser = Schema.encodeSync(UserData)
|
||||
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
||||
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
||||
const decodeAdmittedEvent = Schema.decodeUnknownOption(SessionEvent.InputAdmitted.data)
|
||||
const admittedEventType = Bus.versionedType(
|
||||
SessionEvent.InputAdmitted.type,
|
||||
SessionEvent.InputAdmitted.durable.version,
|
||||
)
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
|
||||
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
||||
@@ -103,46 +97,35 @@ export const compaction = Effect.fn("SessionPending.compaction")(function* (
|
||||
return entry.type === "compaction" ? entry : undefined
|
||||
})
|
||||
|
||||
/**
|
||||
* Reconstruct the admitted record for a pending row that was already consumed
|
||||
* by promotion. The projected `session_message` row proves promotion happened;
|
||||
* the durable `session.input.admitted` event retains the exact admitted
|
||||
* message, including delivery.
|
||||
*/
|
||||
const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(function* (
|
||||
const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
id: SessionMessage.ID,
|
||||
delivery: Delivery,
|
||||
) {
|
||||
const message = yield* db
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (message === undefined) return undefined
|
||||
if (message.session_id !== sessionID || (message.type !== "user" && message.type !== "synthetic"))
|
||||
if (row === undefined) return undefined
|
||||
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
|
||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, admittedEventType)))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
for (const row of rows) {
|
||||
const decoded = decodeAdmittedEvent(row.data)
|
||||
if (decoded._tag !== "Some" || decoded.value.inputID !== id) continue
|
||||
const base = {
|
||||
id,
|
||||
sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(row.created),
|
||||
}
|
||||
return decoded.value.input.type === "user"
|
||||
? User.make({ ...base, ...decoded.value.input })
|
||||
: Synthetic.make({ ...base, ...decoded.value.input })
|
||||
}
|
||||
// A projected message without an admitted event in this aggregate (for
|
||||
// example fork-copied history) is not a retryable admission.
|
||||
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const base = { id, sessionID, timeCreated: message.time.created, delivery }
|
||||
if (message.type === "user")
|
||||
return User.make({
|
||||
...base,
|
||||
type: "user",
|
||||
data: decodeUser(message),
|
||||
})
|
||||
if (message.type === "synthetic")
|
||||
return Synthetic.make({
|
||||
...base,
|
||||
type: "synthetic",
|
||||
data: decodeSynthetic(message),
|
||||
})
|
||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||
})
|
||||
|
||||
@@ -160,7 +143,7 @@ export const admit = Effect.fn("SessionPending.admit")(function* (
|
||||
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
||||
return existing
|
||||
}
|
||||
const promoted = yield* promotedFromHistory(db, request.sessionID, request.id)
|
||||
const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.input.delivery)
|
||||
if (promoted !== undefined) return promoted
|
||||
return yield* bus
|
||||
.publish(SessionEvent.InputAdmitted, {
|
||||
@@ -426,7 +409,7 @@ const publish = Effect.fn("SessionPending.publish")(function* (
|
||||
.pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof LifecycleConflict
|
||||
? promotedFromHistory(db, sessionID, entry.id).pipe(
|
||||
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
|
||||
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
|
||||
)
|
||||
: Effect.die(defect),
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
export * as SessionProjector from "./projector"
|
||||
|
||||
import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, gte, lt, lte, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { Bus } from "../bus"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Model } from "../model"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionV1 } from "../v1/session"
|
||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionPending } from "./pending"
|
||||
import { Workspace } from "../workspace"
|
||||
import { InstructionState } from "./instruction-state"
|
||||
import { MessageTable, PartTable, SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import { Slug } from "../util/slug"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import type { SessionSchema } from "./schema"
|
||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
type MessageEvent = Exclude<
|
||||
CurrentDurableEvent,
|
||||
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type | typeof SessionEvent.InstructionsUpdated.Type
|
||||
>
|
||||
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
@@ -50,73 +46,7 @@ const forkTitle = (value?: string) => {
|
||||
return `${value} (fork #1)`
|
||||
}
|
||||
|
||||
function usage(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"] | unknown): Usage | undefined {
|
||||
if (typeof part !== "object" || part === null) return undefined
|
||||
const value = part as Record<string, unknown>
|
||||
if (value.type !== "step-finish") return undefined
|
||||
if (!("cost" in value) || !("tokens" in value)) return undefined
|
||||
return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] }
|
||||
}
|
||||
|
||||
function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInsert {
|
||||
return {
|
||||
id: info.id,
|
||||
project_id: info.projectID,
|
||||
workspace_id: info.workspaceID ?? null,
|
||||
parent_id: info.parentID,
|
||||
slug: info.slug,
|
||||
directory: info.directory,
|
||||
path: info.path,
|
||||
title: info.title,
|
||||
agent: info.agent,
|
||||
model: info.model,
|
||||
version: info.version,
|
||||
share_url: info.share?.url,
|
||||
summary_additions: info.summary?.additions,
|
||||
summary_deletions: info.summary?.deletions,
|
||||
summary_files: info.summary?.files,
|
||||
summary_diffs: info.summary?.diffs ? [...info.summary.diffs] : undefined,
|
||||
metadata: info.metadata,
|
||||
cost: info.cost ?? 0,
|
||||
tokens_input: (info.tokens ?? { input: 0 }).input,
|
||||
tokens_output: (info.tokens ?? { output: 0 }).output,
|
||||
tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning,
|
||||
tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read,
|
||||
tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write,
|
||||
revert: info.revert
|
||||
? {
|
||||
messageID: SessionMessage.ID.make(info.revert.messageID),
|
||||
partID: info.revert.partID,
|
||||
snapshot: info.revert.snapshot,
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
: null,
|
||||
permission: info.permission ? [...info.permission] : undefined,
|
||||
time_created: info.time.created,
|
||||
time_updated: info.time.updated,
|
||||
time_compacting: info.time.compacting,
|
||||
time_archived: info.time.archived,
|
||||
}
|
||||
}
|
||||
|
||||
function messageData(
|
||||
info: (typeof SessionV1.Event.MessageUpdated.Type)["data"]["info"],
|
||||
): typeof MessageTable.$inferInsert.data {
|
||||
const { id: _, sessionID: __, ...rest } = info
|
||||
return rest as DeepMutable<typeof rest>
|
||||
}
|
||||
|
||||
function partData(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"]): typeof PartTable.$inferInsert.data {
|
||||
const { id: _, messageID: __, sessionID: ___, ...rest } = part
|
||||
return rest as DeepMutable<typeof rest>
|
||||
}
|
||||
|
||||
function applyUsage(
|
||||
db: DatabaseService,
|
||||
sessionID: (typeof SessionV1.Event.MessageUpdated.Type)["data"]["sessionID"],
|
||||
value: Usage,
|
||||
sign = 1,
|
||||
) {
|
||||
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage, sign = 1) {
|
||||
return db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
@@ -255,66 +185,22 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
.pipe(Effect.orDie)
|
||||
if (rows.length === 0) break
|
||||
|
||||
const idMap = new Map(rows.map((row) => [row.id, SessionMessage.ID.create()]))
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values(
|
||||
rows.map((row) => {
|
||||
const id = idMap.get(row.id)
|
||||
if (!id) throw new Error(`Fork message ID mapping missing: ${row.id}`)
|
||||
return {
|
||||
id,
|
||||
session_id: event.data.sessionID,
|
||||
type: row.type,
|
||||
seq: row.seq,
|
||||
time_created: row.time_created,
|
||||
time_updated: row.time_updated,
|
||||
data: row.data,
|
||||
}
|
||||
}),
|
||||
rows.map((row) => ({
|
||||
id: SessionMessage.ID.create(),
|
||||
session_id: event.data.sessionID,
|
||||
type: row.type,
|
||||
seq: row.seq,
|
||||
time_created: row.time_created,
|
||||
time_updated: row.time_updated,
|
||||
data: row.data,
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const pendingRows = yield* db
|
||||
.select()
|
||||
.from(SessionPendingTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionPendingTable.session_id, event.data.parentID),
|
||||
inArray(
|
||||
SessionPendingTable.id,
|
||||
rows.map((row) => row.id),
|
||||
),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
if (pendingRows.length > 0) {
|
||||
yield* db
|
||||
.insert(SessionPendingTable)
|
||||
.values(
|
||||
pendingRows.flatMap((row) => {
|
||||
const id = idMap.get(row.id)
|
||||
return id && row.type !== "compaction"
|
||||
? [
|
||||
{
|
||||
id,
|
||||
session_id: event.data.sessionID,
|
||||
type: row.type,
|
||||
data: row.data,
|
||||
delivery: row.delivery,
|
||||
admitted_seq: row.admitted_seq,
|
||||
time_created: row.time_created,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
cursor = rows.at(-1)!.seq
|
||||
}
|
||||
if (copiedSeq !== undefined) yield* Bus.reserveSequence(db, event.data.sessionID, copiedSeq)
|
||||
@@ -466,34 +352,39 @@ const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const db = (yield* Database.Service).db
|
||||
yield* bus.project(SessionV1.Event.Created, (event) =>
|
||||
yield* bus.project(SessionEvent.Created, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const stored = yield* db
|
||||
.insert(SessionTable)
|
||||
.values(sessionRow(event.data.info))
|
||||
.values({
|
||||
id: event.data.sessionID,
|
||||
project_id: event.data.projectID,
|
||||
workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
|
||||
parent_id: event.data.parentID,
|
||||
slug: event.data.slug,
|
||||
directory: event.data.location.directory,
|
||||
path: event.data.subpath,
|
||||
title: event.data.title,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
version: event.data.version,
|
||||
time_created: DateTime.toEpochMillis(event.created),
|
||||
time_updated: DateTime.toEpochMillis(event.created),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ sessionID: SessionTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
|
||||
if (event.data.info.workspaceID) {
|
||||
yield* db
|
||||
.update(WorkspaceTable)
|
||||
.set({ time_used: Date.now() })
|
||||
.where(eq(WorkspaceTable.id, event.data.info.workspaceID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
if (!event.data.location.workspaceID) return
|
||||
yield* db
|
||||
.update(WorkspaceTable)
|
||||
.set({ time_used: Date.now() })
|
||||
.where(eq(WorkspaceTable.id, event.data.location.workspaceID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionV1.Event.Updated, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set(sessionRow(event.data.info))
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Moved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* db
|
||||
@@ -511,81 +402,9 @@ const layer = Layer.effectDiscard(
|
||||
yield* InstructionState.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionV1.Event.Deleted, (event) =>
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Deleted, (event) =>
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionV1.Event.MessageUpdated, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const time_created = event.data.info.time.created
|
||||
const id = event.data.info.id
|
||||
const sessionID = event.data.info.sessionID
|
||||
const data = messageData(event.data.info)
|
||||
yield* db
|
||||
.insert(MessageTable)
|
||||
.values({ id, session_id: sessionID, time_created, data })
|
||||
.onConflictDoUpdate({ target: MessageTable.id, set: { data } })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionV1.Event.MessageRemoved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(PartTable)
|
||||
.where(and(eq(PartTable.message_id, event.data.messageID), eq(PartTable.session_id, event.data.sessionID)))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
for (const row of rows) {
|
||||
const previous = usage(row.data)
|
||||
if (previous) yield* applyUsage(db, event.data.sessionID, previous, -1)
|
||||
}
|
||||
yield* db
|
||||
.delete(MessageTable)
|
||||
.where(and(eq(MessageTable.id, event.data.messageID), eq(MessageTable.session_id, event.data.sessionID)))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionV1.Event.PartRemoved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(PartTable)
|
||||
.where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const previous = row && usage(row.data)
|
||||
if (previous) yield* applyUsage(db, event.data.sessionID, previous, -1)
|
||||
yield* db
|
||||
.delete(PartTable)
|
||||
.where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID)))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionV1.Event.PartUpdated, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const id = event.data.part.id
|
||||
const messageID = event.data.part.messageID
|
||||
const sessionID = event.data.part.sessionID
|
||||
const data = partData(event.data.part)
|
||||
const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(PartTable)
|
||||
.values({ id, message_id: messageID, session_id: sessionID, time_created: event.data.time, data })
|
||||
.onConflictDoUpdate({ target: PartTable.id, set: { data } })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const previous = row && usage(row.data)
|
||||
const next = usage(event.data.part)
|
||||
if (previous) yield* applyUsage(db, row.session_id, previous, -1)
|
||||
if (next) yield* applyUsage(db, sessionID, next)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.AgentSelected, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
@@ -682,7 +501,10 @@ const layer = Layer.effectDiscard(
|
||||
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
|
||||
InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta),
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
yield* InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Skill.Activated, (event) => run(db, event))
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { PermissionV1 } from "../v1/permission"
|
||||
import { Project } from "../project"
|
||||
import type { SessionSchema } from "./schema"
|
||||
import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
||||
import { Workspace } from "../workspace"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { Instruction } from "@opencode-ai/schema/instruction"
|
||||
@@ -18,11 +17,9 @@ import type { RevertV1 } from "@opencode-ai/schema/session-revert"
|
||||
import type { Schema } from "effect"
|
||||
|
||||
type SessionMessageData = Omit<(typeof SessionMessage.Info)["Encoded"], "type" | "id">
|
||||
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
||||
type V1PartData = Omit<SessionV1.Part, "id" | "sessionID" | "messageID">
|
||||
|
||||
export const SessionTable = sqliteTable(
|
||||
"session",
|
||||
"session_v2",
|
||||
{
|
||||
id: text().$type<SessionSchema.ID>().primaryKey(),
|
||||
project_id: text()
|
||||
@@ -64,47 +61,15 @@ export const SessionTable = sqliteTable(
|
||||
time_suspended: integer(),
|
||||
},
|
||||
(table) => [
|
||||
index("session_project_idx").on(table.project_id),
|
||||
index("session_workspace_idx").on(table.workspace_id),
|
||||
index("session_parent_idx").on(table.parent_id),
|
||||
index("session_time_suspended_idx")
|
||||
index("session_v2_project_idx").on(table.project_id),
|
||||
index("session_v2_workspace_idx").on(table.workspace_id),
|
||||
index("session_v2_parent_idx").on(table.parent_id),
|
||||
index("session_v2_time_suspended_idx")
|
||||
.on(table.time_suspended)
|
||||
.where(sql`${table.time_suspended} is not null`),
|
||||
],
|
||||
)
|
||||
|
||||
export const MessageTable = sqliteTable(
|
||||
"message",
|
||||
{
|
||||
id: text().$type<MessageID>().primaryKey(),
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<V1MessageData>(),
|
||||
},
|
||||
(table) => [index("message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id)],
|
||||
)
|
||||
|
||||
export const PartTable = sqliteTable(
|
||||
"part",
|
||||
{
|
||||
id: text().$type<PartID>().primaryKey(),
|
||||
message_id: text()
|
||||
.$type<MessageID>()
|
||||
.notNull()
|
||||
.references(() => MessageTable.id, { onDelete: "cascade" }),
|
||||
session_id: text().$type<SessionSchema.ID>().notNull(),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<V1PartData>(),
|
||||
},
|
||||
(table) => [
|
||||
index("part_message_id_id_idx").on(table.message_id, table.id),
|
||||
index("part_session_idx").on(table.session_id),
|
||||
],
|
||||
)
|
||||
|
||||
export const SessionMessageTable = sqliteTable(
|
||||
"session_message",
|
||||
{
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { SessionTable } from "../session/sql"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
|
||||
export const SessionShareTable = sqliteTable("session_share", {
|
||||
session_id: text()
|
||||
.primaryKey()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
id: text().notNull(),
|
||||
secret: text().notNull(),
|
||||
url: text().notNull(),
|
||||
...Timestamps,
|
||||
})
|
||||
@@ -1,15 +1,12 @@
|
||||
export * as ShellSelect from "./select"
|
||||
|
||||
import path from "path"
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { readFile } from "fs/promises"
|
||||
import { statSync } from "fs"
|
||||
import { setTimeout } from "node:timers/promises"
|
||||
import { Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { which } from "../util/which"
|
||||
|
||||
const SIGKILL_TIMEOUT_MS = 200
|
||||
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
|
||||
bash: { login: true, posix: true },
|
||||
dash: { login: true, posix: true },
|
||||
@@ -33,37 +30,6 @@ export const Options = Schema.Struct({
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
|
||||
const pid = proc.pid
|
||||
if (!pid || opts?.exited?.()) return
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await new Promise<void>((resolve) => {
|
||||
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
killer.once("exit", () => resolve())
|
||||
killer.once("error", () => resolve())
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM")
|
||||
await setTimeout(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
}
|
||||
} catch {
|
||||
proc.kill("SIGTERM")
|
||||
await setTimeout(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
proc.kill("SIGKILL")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stat(file: string) {
|
||||
return statSync(file, { throwIfNoEntry: false }) ?? undefined
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Hash } from "@opencode-ai/util/hash"
|
||||
export { ID }
|
||||
|
||||
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
|
||||
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
|
||||
operation: Schema.Literals(["capture", "files", "diff", "restore"]),
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
@@ -36,10 +36,6 @@ export interface RestoreInput {
|
||||
readonly files: ReadonlyMap<RelativePath, ID>
|
||||
}
|
||||
|
||||
export interface PreviewInput extends RestoreInput {
|
||||
readonly context?: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Capture the current Location-scoped filesystem state as a content-addressed
|
||||
@@ -60,25 +56,11 @@ export interface Interface {
|
||||
*/
|
||||
readonly diff: (input: DiffInput) => Effect.Effect<readonly File.Diff[], Error>
|
||||
|
||||
/**
|
||||
* Preview the filesystem result of a selective restore without modifying the
|
||||
* worktree. Each project-relative path maps to the tree it would be restored
|
||||
* from.
|
||||
*/
|
||||
readonly preview: (input: PreviewInput) => Effect.Effect<readonly File.Diff[], Error>
|
||||
|
||||
/**
|
||||
* Restore selected project-relative paths from their associated trees. A path
|
||||
* absent from its selected tree is removed; paths outside the map are untouched.
|
||||
*/
|
||||
*/
|
||||
readonly restore: (input: RestoreInput) => Effect.Effect<void, Error>
|
||||
|
||||
/**
|
||||
* Replace the snapshot index with a captured tree and check out all its entries.
|
||||
* Files absent from the tree remain untouched. Prefer selective `restore` when
|
||||
* only known paths should change.
|
||||
*/
|
||||
readonly checkout: (snapshot: ID) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Snapshot") {}
|
||||
@@ -176,59 +158,26 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
})
|
||||
|
||||
const plan = Effect.fnUntraced(function* (
|
||||
operation: "preview" | "restore",
|
||||
worktree: AbsolutePath,
|
||||
input: RestoreInput,
|
||||
) {
|
||||
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
|
||||
const files = new Map<RelativePath, Git.TreeID>()
|
||||
for (const [file, snapshot] of input.files) {
|
||||
const absolute = path.resolve(worktree, file)
|
||||
if (!FSUtil.contains(worktree, absolute))
|
||||
return yield* new Error({ operation, message: `Path escapes the project: ${file}` })
|
||||
return yield* new Error({ operation: "restore", message: `Path escapes the project: ${file}` })
|
||||
files.set(file, Git.TreeID.make(snapshot))
|
||||
}
|
||||
return files
|
||||
})
|
||||
|
||||
const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) {
|
||||
if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" })
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
||||
const files = yield* plan("preview", repo.worktree, input)
|
||||
const current = yield* git.tree
|
||||
.capture({
|
||||
repository: repo.snapshotRepository,
|
||||
scopes: Array.from(files.keys()),
|
||||
ignores: repo.source,
|
||||
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
||||
return yield* git.tree
|
||||
.preview({
|
||||
repository: repo.snapshotRepository,
|
||||
current,
|
||||
files,
|
||||
context: input.context,
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
||||
})
|
||||
|
||||
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
|
||||
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
yield* git.tree
|
||||
.restore({ repository: repo.snapshotRepository, files: yield* plan("restore", repo.worktree, input) })
|
||||
.restore({ repository: repo.snapshotRepository, files: yield* plan(repo.worktree, input) })
|
||||
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
})
|
||||
|
||||
const checkout = Effect.fn("Snapshot.checkout")(function* (snapshot: ID) {
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
yield* git.tree
|
||||
.checkout({ repository: repo.snapshotRepository, tree: Git.TreeID.make(snapshot) })
|
||||
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
})
|
||||
|
||||
return Service.of({ capture, files, diff, preview, restore, checkout })
|
||||
return Service.of({ capture, files, diff, restore })
|
||||
}).pipe(Effect.withSpan("Snapshot.boot")),
|
||||
)
|
||||
|
||||
@@ -244,9 +193,7 @@ export const noopLayer = Layer.succeed(
|
||||
capture: () => Effect.succeed(undefined),
|
||||
files: () => Effect.succeed([]),
|
||||
diff: () => Effect.succeed([]),
|
||||
preview: () => Effect.succeed([]),
|
||||
restore: () => Effect.void,
|
||||
checkout: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
export * as SessionV1 from "./session"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export {
|
||||
AgentPart,
|
||||
AgentPartInput,
|
||||
Assistant,
|
||||
CompactionPart,
|
||||
Event,
|
||||
FilePart,
|
||||
FilePartInput,
|
||||
FilePartSource,
|
||||
FileSource,
|
||||
Format,
|
||||
Info,
|
||||
MessageID,
|
||||
OutputFormatJsonSchema,
|
||||
OutputFormatText,
|
||||
Part,
|
||||
PartID,
|
||||
PatchPart,
|
||||
Range,
|
||||
ReasoningPart,
|
||||
ResourceSource,
|
||||
RetryPart,
|
||||
SessionInfo,
|
||||
SnapshotPart,
|
||||
StepFinishPart,
|
||||
StepStartPart,
|
||||
SubtaskPart,
|
||||
SubtaskPartInput,
|
||||
SymbolSource,
|
||||
TextPart,
|
||||
TextPartInput,
|
||||
ToolPart,
|
||||
ToolState,
|
||||
ToolStateCompleted,
|
||||
ToolStateError,
|
||||
ToolStatePending,
|
||||
ToolStateRunning,
|
||||
User,
|
||||
WithParts,
|
||||
} from "@opencode-ai/schema/session-v1"
|
||||
|
||||
export const OutputLengthError = NamedError.create("MessageOutputLengthError", {})
|
||||
export const AuthError = NamedError.create("ProviderAuthError", { providerID: Schema.String, message: Schema.String })
|
||||
export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String })
|
||||
export const StructuredOutputError = NamedError.create("StructuredOutputError", {
|
||||
message: Schema.String,
|
||||
retries: NonNegativeInt,
|
||||
})
|
||||
export const APIError = NamedError.create("APIError", {
|
||||
message: Schema.String,
|
||||
statusCode: Schema.optional(NonNegativeInt),
|
||||
isRetryable: Schema.Boolean,
|
||||
responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
responseBody: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
export type APIError = Schema.Schema.Type<typeof APIError.Schema>
|
||||
export const ContextOverflowError = NamedError.create("ContextOverflowError", {
|
||||
message: Schema.String,
|
||||
responseBody: Schema.optional(Schema.String),
|
||||
})
|
||||
export const ContentFilterError = NamedError.create("ContentFilterError", { message: Schema.String })
|
||||
@@ -4,7 +4,6 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -89,10 +88,10 @@ const VersionedMessage = Bus.durable({
|
||||
},
|
||||
})
|
||||
|
||||
const DurableMessage = SessionV1.Event.MessageRemoved
|
||||
const DurableMessage = SessionEvent.Renamed
|
||||
const durableData = (sessionID: Session.ID, text: string) => ({
|
||||
sessionID,
|
||||
messageID: SessionV1.MessageID.ascending(`msg_${text}`),
|
||||
title: text,
|
||||
})
|
||||
|
||||
/** Followed log read without markers: the old `durable` stream shape. */
|
||||
@@ -1298,24 +1297,4 @@ describe("Bus", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sequences returns the latest committed seq per aggregate and omits unknown aggregates", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const first = Session.ID.create()
|
||||
const second = Session.ID.create()
|
||||
yield* bus.publish(DurableMessage, durableData(first, "zero"))
|
||||
yield* bus.publish(DurableMessage, durableData(first, "one"))
|
||||
yield* bus.publish(DurableMessage, durableData(second, "zero"))
|
||||
|
||||
const sequences = yield* bus.sequences([first, second, Session.ID.create()])
|
||||
|
||||
expect(sequences).toEqual(
|
||||
new Map([
|
||||
[first, Event.Seq.make(1)],
|
||||
[second, Event.Seq.make(0)],
|
||||
]),
|
||||
)
|
||||
expect(yield* bus.sequences([])).toEqual(new Map())
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,7 +80,6 @@ describe("node build", () => {
|
||||
list: () => Effect.succeed([]),
|
||||
directories: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
commit: () => Effect.void,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -89,68 +89,6 @@ describe("FileMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects create when a prospective target appears after resolution", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "appeared.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "appeared.txt" })
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "winner"))
|
||||
|
||||
expect(
|
||||
yield* (yield* FileMutation.Service).create({ target, content: "replacement" }).pipe(Effect.flip),
|
||||
).toMatchObject({
|
||||
_tag: "FileMutation.TargetExistsError",
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("winner")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("creates when an existing target disappears after resolution", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "removed.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "removed.txt" })
|
||||
yield* Effect.promise(() => fs.rm(targetPath))
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).create({ target, content: "after" })).toEqual({
|
||||
operation: "write",
|
||||
target: target.canonical,
|
||||
resource: "removed.txt",
|
||||
existed: false,
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("removes an existing internal file", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "remove.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "remove"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "remove.txt" })
|
||||
const result = yield* (yield* FileMutation.Service).remove({ target })
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "remove",
|
||||
target: target.canonical,
|
||||
resource: "remove.txt",
|
||||
existed: true,
|
||||
})
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
fs.stat(targetPath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("writes an explicitly resolved external target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
@@ -171,49 +109,6 @@ describe("FileMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("removes an explicitly resolved external target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "external.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "external"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const result = yield* (yield* FileMutation.Service).remove({ target })
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "remove",
|
||||
target: target.canonical,
|
||||
resource: target.resource,
|
||||
existed: true,
|
||||
})
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
fs.stat(targetPath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports a missing target as not removed without checking existence first", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "missing.txt" })
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).remove({ target })).toEqual({
|
||||
operation: "remove",
|
||||
target: target.canonical,
|
||||
resource: "missing.txt",
|
||||
existed: false,
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent writes to the same canonical target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -257,63 +152,6 @@ describe("FileMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows only one concurrent conditional write based on the same bytes", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "shared.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
let writes = 0
|
||||
const filesystem = instrumentWrites((write) =>
|
||||
Effect.gen(function* () {
|
||||
writes++
|
||||
if (writes === 1) {
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(releaseFirst)
|
||||
}
|
||||
yield* write
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const expected = new TextEncoder().encode("initial")
|
||||
const first = yield* files.writeIfUnchanged({ target, expected, content: "first" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* files
|
||||
.writeIfUnchanged({ target, expected, content: "second" })
|
||||
.pipe(Effect.flip, Effect.forkChild)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
expect(yield* Fiber.join(second)).toMatchObject({ _tag: "FileMutation.StaleContentError" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("first")
|
||||
expect(writes).toBe(1)
|
||||
}).pipe(provide(directory, filesystem))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a conditional write when target content is already stale", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "stale.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "current"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "stale.txt" })
|
||||
|
||||
expect(
|
||||
yield* (yield* FileMutation.Service)
|
||||
.writeIfUnchanged({ target, expected: new TextEncoder().encode("older"), content: "replacement" })
|
||||
.pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "FileMutation.StaleContentError", path: target.canonical })
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("current")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows distinct canonical targets to proceed independently", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,44 +1,59 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Protected } from "@opencode-ai/core/filesystem/protected"
|
||||
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { location } from "../fixture/location"
|
||||
|
||||
const it = testEffect(LayerNode.compile(Ripgrep.node))
|
||||
describe("FileSystemSearch", () => {
|
||||
test("bounds a home scan even when home is detected as a repository", async () => {
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const home = AbsolutePath.make(os.homedir())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: home }, { vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
observed = input
|
||||
if (input.onEntry)
|
||||
yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" }))
|
||||
return []
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
const withTmp = <A, E, R>(f: (directory: AbsolutePath) => Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(AbsolutePath.make(tmp.path))))
|
||||
|
||||
describe("Ripgrep", () => {
|
||||
it.live("globs files as an array", () =>
|
||||
withTmp((cwd) =>
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
|
||||
const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 })
|
||||
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("greps files with include filtering", () =>
|
||||
withTmp((cwd) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n"))
|
||||
const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 })
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts"))
|
||||
expect(result[0]?.submatches[0]?.text).toBe("needle")
|
||||
}),
|
||||
),
|
||||
)
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(observed?.limit).toBe(100_000)
|
||||
expect(observed?.exclude).toEqual([...Protected.names()].map((name) => `${name}/**`))
|
||||
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
|
||||
RelativePath.make(`src${path.sep}`),
|
||||
)
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,52 +56,22 @@ function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>)
|
||||
}
|
||||
|
||||
describe("Formatter", () => {
|
||||
it.live("status() returns empty list when no formatters are configured", () =>
|
||||
it.live("does not run formatters marked as disabled in config", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() returns built-in formatters when formatter is true", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* formatter.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt?.extensions).toContain(".go")
|
||||
}),
|
||||
).pipe(Effect.provide(formatterLayer(directory, true))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() keeps built-in formatters when config object is provided", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* formatter.status()
|
||||
expect(statuses.find((item) => item.name === "gofmt")?.extensions).toContain(".go")
|
||||
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
|
||||
}),
|
||||
).pipe(Effect.provide(formatterLayer(directory, { gofmt: {} }))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() excludes formatters marked as disabled in config", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* formatter.status()
|
||||
expect(statuses.find((item) => item.name === "gofmt")).toBeUndefined()
|
||||
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
|
||||
}),
|
||||
).pipe(Effect.provide(formatterLayer(directory, { gofmt: { disabled: true } }))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("service initializes without error", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) => formatter.init()).pipe(Effect.provide(formatterLayer(directory))),
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.disabled")
|
||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
formatterLayer(directory, {
|
||||
disabled: {
|
||||
disabled: true,
|
||||
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||
extensions: [".disabled"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -115,22 +85,29 @@ describe("Formatter", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() initializes formatter state per directory", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([off, on]) =>
|
||||
it.live("loads formatter state per directory", () =>
|
||||
withTemp((off) =>
|
||||
withTemp((on) =>
|
||||
Effect.gen(function* () {
|
||||
const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
|
||||
Effect.provide(formatterLayer(off.path, false)),
|
||||
const offFile = path.join(off, "test.isolated")
|
||||
const onFile = path.join(on, "test.isolated")
|
||||
const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe(
|
||||
Effect.provide(formatterLayer(off, false)),
|
||||
)
|
||||
const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
|
||||
Effect.provide(formatterLayer(on.path, true)),
|
||||
const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe(
|
||||
Effect.provide(
|
||||
formatterLayer(on, {
|
||||
isolated: {
|
||||
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||
extensions: [".isolated"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(disabled).toEqual([])
|
||||
expect(enabled.find((item) => item.name === "gofmt")).toBeDefined()
|
||||
expect(disabled).toBe(false)
|
||||
expect(enabled).toBe(true)
|
||||
}),
|
||||
(directories) =>
|
||||
Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -185,9 +185,6 @@ describe("Git trees", () => {
|
||||
])
|
||||
|
||||
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||
const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
|
||||
expect(preview).toHaveLength(1)
|
||||
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
|
||||
yield* git.tree.restore({ repository, files })
|
||||
expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
|
||||
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
|
||||
|
||||
@@ -14,7 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { InstructionBlobTable, InstructionStateTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { InstructionBlobTable, InstructionStateTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node])))
|
||||
@@ -105,6 +105,7 @@ describe("InstructionState", () => {
|
||||
expect(observation).toEqual({
|
||||
sessionID,
|
||||
initial: true,
|
||||
previous: {},
|
||||
current: {
|
||||
"test/first": Instructions.hash("first"),
|
||||
"test/second": Instructions.hash("second"),
|
||||
@@ -156,7 +157,7 @@ describe("InstructionState", () => {
|
||||
|
||||
const initial = yield* InstructionState.observe(db, instructions, sessionID)
|
||||
expect(reads).toBe(2)
|
||||
yield* InstructionState.commit(db, events, initial)
|
||||
yield* InstructionState.commit(db, events, instructions, initial)
|
||||
expect(reads).toBe(2)
|
||||
|
||||
current = "changed"
|
||||
@@ -166,6 +167,10 @@ describe("InstructionState", () => {
|
||||
expect(changed).toMatchObject({
|
||||
sessionID,
|
||||
initial: false,
|
||||
previous: {
|
||||
"test/current": Instructions.hash("initial"),
|
||||
"test/retired": Instructions.hash("retired"),
|
||||
},
|
||||
current: { "test/current": Instructions.hash("changed") },
|
||||
delta: {
|
||||
"test/current": Instructions.hash("changed"),
|
||||
@@ -173,7 +178,7 @@ describe("InstructionState", () => {
|
||||
},
|
||||
blobs: { [Instructions.hash("changed")]: "changed" },
|
||||
})
|
||||
yield* InstructionState.commit(db, events, changed)
|
||||
yield* InstructionState.commit(db, events, instructions, changed)
|
||||
expect(reads).toBe(4)
|
||||
yield* unsubscribe
|
||||
|
||||
@@ -190,6 +195,11 @@ describe("InstructionState", () => {
|
||||
"test/retired": "removed",
|
||||
},
|
||||
])
|
||||
// The chronological update text is frozen into the event; the baseline has none.
|
||||
expect((yield* instructionEvents(db, sessionID)).map((event) => event.data.text)).toEqual([
|
||||
undefined,
|
||||
"changed\n\nRemoved retired",
|
||||
])
|
||||
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toMatchObject({
|
||||
initial_values: {
|
||||
"test/current": Instructions.hash("initial"),
|
||||
@@ -222,18 +232,19 @@ describe("InstructionState", () => {
|
||||
expect(observation).toEqual({
|
||||
sessionID,
|
||||
initial: false,
|
||||
previous: { "test/context": Instructions.hash("unchanged") },
|
||||
current: { "test/context": Instructions.hash("unchanged") },
|
||||
delta: {},
|
||||
blobs: {},
|
||||
})
|
||||
yield* InstructionState.commit(db, events, observation)
|
||||
yield* InstructionState.commit(db, events, instructions, observation)
|
||||
|
||||
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles a fresh private update without repairing a missing cache", () =>
|
||||
it.effect("treats a missing state row as a fresh baseline without repairing it", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = SessionSchema.ID.make("ses_instruction_generate")
|
||||
const { db, events } = yield* setup(sessionID)
|
||||
@@ -254,7 +265,7 @@ describe("InstructionState", () => {
|
||||
|
||||
const assembled = yield* preview(db, sessionID, instructions)
|
||||
|
||||
expect(assembled).toEqual({ initial: "Initial context", updates: [], update: "Changed context" })
|
||||
expect(assembled).toEqual({ initial: "Changed context", update: "" })
|
||||
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
||||
expect(
|
||||
@@ -268,7 +279,7 @@ describe("InstructionState", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads through a stale cache without repairing it", () =>
|
||||
it.effect("trusts the projected state without consulting durable events", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = SessionSchema.ID.make("ses_instruction_generate_stale")
|
||||
const { db, events } = yield* setup(sessionID)
|
||||
@@ -280,6 +291,7 @@ describe("InstructionState", () => {
|
||||
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||
value = "Committed update"
|
||||
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||
// Tamper with the projected state; the authoritative row wins over event history.
|
||||
yield* db
|
||||
.update(InstructionStateTable)
|
||||
.set({ through_seq: 0, current_values: { "test/context": Instructions.hash("Initial context") } })
|
||||
@@ -294,7 +306,6 @@ describe("InstructionState", () => {
|
||||
const assembled = yield* preview(db, sessionID, instructions)
|
||||
|
||||
expect(assembled.initial).toBe("Initial context")
|
||||
expect(assembled.updates.map((entry) => entry.message.text)).toEqual(["Committed update"])
|
||||
expect(assembled.update).toBe("Private update")
|
||||
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
||||
@@ -302,6 +313,41 @@ describe("InstructionState", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists chronological updates as system messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = SessionSchema.ID.make("ses_instruction_messages")
|
||||
const { db, events } = yield* setup(sessionID)
|
||||
let value = "Initial context"
|
||||
const instructions = source(
|
||||
"test/context",
|
||||
Effect.sync(() => value),
|
||||
)
|
||||
const messages = () =>
|
||||
db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "system")))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
// The initial baseline is not chronological history and produces no message.
|
||||
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||
expect(yield* messages()).toEqual([])
|
||||
|
||||
value = "Changed context"
|
||||
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||
const rows = yield* messages()
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]?.data).toMatchObject({ text: "Changed context" })
|
||||
expect(rows.map((row) => row.seq)).toEqual([(yield* instructionEvents(db, sessionID)).at(-1)!.seq])
|
||||
|
||||
// A no-op observation adds nothing.
|
||||
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||
expect(yield* messages()).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles initial instructions without persisting a baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = SessionSchema.ID.make("ses_instruction_generate_initial")
|
||||
@@ -310,7 +356,6 @@ describe("InstructionState", () => {
|
||||
|
||||
expect(yield* preview(db, sessionID, instructions)).toEqual({
|
||||
initial: "Initial context",
|
||||
updates: [],
|
||||
update: "",
|
||||
})
|
||||
expect(yield* instructionEvents(db, sessionID)).toEqual([])
|
||||
@@ -336,7 +381,6 @@ describe("InstructionState", () => {
|
||||
|
||||
expect(yield* preview(db, sessionID, instructions)).toEqual({
|
||||
initial: "Committed context",
|
||||
updates: [],
|
||||
update: "",
|
||||
})
|
||||
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||
@@ -388,7 +432,7 @@ describe("InstructionState", () => {
|
||||
for (const next of ["initial", "changed", "changed", Instructions.removed] as const) {
|
||||
value = next
|
||||
yield* InstructionState.observe(db, observedInstructions, observedSessionID).pipe(
|
||||
Effect.flatMap((observation) => InstructionState.commit(db, events, observation)),
|
||||
Effect.flatMap((observation) => InstructionState.commit(db, events, observedInstructions, observation)),
|
||||
)
|
||||
yield* InstructionState.prepare(db, events, preparedInstructions, preparedSessionID)
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { SessionV1 as Wire } from "@opencode-ai/schema/session-v1"
|
||||
import { SessionV1 } from "../src/v1/session"
|
||||
|
||||
describe("legacy event schema compatibility", () => {
|
||||
test("Core references canonical SessionV1 definitions", () => {
|
||||
expect(SessionV1.Event.Created).toBe(Wire.Event.Created)
|
||||
expect(SessionV1.Event.PartUpdated).toBe(Wire.Event.PartUpdated)
|
||||
})
|
||||
|
||||
test("Core retains NamedError constructor identity", () => {
|
||||
const error = new SessionV1.APIError({ message: "failed", isRetryable: false })
|
||||
expect(error).toBeInstanceOf(SessionV1.APIError)
|
||||
expect(error.toObject()).toEqual({ name: "APIError", data: { message: "failed", isRetryable: false } })
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,6 @@ const projectLayer = Layer.succeed(
|
||||
canonical: AbsolutePath.make("/main/repo"),
|
||||
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
|
||||
}),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
|
||||
|
||||
@@ -101,13 +101,10 @@ describe("RepositoryCache", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns typed validation and clone failures", () =>
|
||||
it.live("returns typed branch validation and clone failures", () =>
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const invalidRepository = yield* Effect.flip(RepositoryCache.parseRemote("not-a-repo"))
|
||||
expect(invalidRepository).toBeInstanceOf(RepositoryCache.InvalidRepositoryError)
|
||||
|
||||
const invalidBranch = yield* Effect.flip(cache.ensure({ reference: fixture.reference, branch: "../unsafe" }))
|
||||
expect(invalidBranch).toBeInstanceOf(RepositoryCache.InvalidBranchError)
|
||||
|
||||
|
||||
@@ -11,6 +11,44 @@ import { testEffect } from "./lib/effect"
|
||||
const it = testEffect(LayerNode.compile(Ripgrep.node))
|
||||
|
||||
describe("Ripgrep", () => {
|
||||
it.live("globs files as an array", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "match.ts"), "needle\n"))
|
||||
|
||||
const result = yield* (yield* Ripgrep.Service).glob({ cwd: tmp.path, pattern: "**/*.ts", limit: 10 })
|
||||
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("greps files with include filtering", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "match.ts"), "needle\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "skip.txt"), "needle\n"))
|
||||
|
||||
const result = yield* (yield* Ripgrep.Service).grep({
|
||||
cwd: tmp.path,
|
||||
pattern: "needle",
|
||||
include: "*.ts",
|
||||
limit: 10,
|
||||
})
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts"))
|
||||
expect(result[0]?.submatches[0]?.text).toBe("needle")
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps ignored files out of catch-all find results", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -63,6 +101,29 @@ describe("Ripgrep", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("excludes protected directory trees from catch-all find results", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "Pictures")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "Pictures", "private.jpg"), "private\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "visible.txt"), "visible\n"))
|
||||
|
||||
const files = yield* (yield* Ripgrep.Service).find({
|
||||
cwd: tmp.path,
|
||||
pattern: "*",
|
||||
limit: 10,
|
||||
exclude: ["Pictures/**"],
|
||||
})
|
||||
|
||||
expect(files.map((item) => item.path)).toContain(RelativePath.make("visible.txt"))
|
||||
expect(files.map((item) => item.path)).not.toContain(RelativePath.make("Pictures/private.jpg"))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns a bounded preview for matches on oversized lines", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -36,7 +36,6 @@ const projects = Layer.succeed(
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
let requests: LLMRequest[] = []
|
||||
|
||||
@@ -16,7 +16,6 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
@@ -34,7 +33,6 @@ const projects = Layer.succeed(
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
@@ -87,7 +85,7 @@ describe("Session.create", () => {
|
||||
|
||||
expect(created.title).toBeUndefined()
|
||||
expect(row?.title).toBeNull()
|
||||
expect(event?.data).not.toHaveProperty("info.title")
|
||||
expect(event?.data).not.toHaveProperty("title")
|
||||
expect((yield* session.create({ location, title: "Explicit title" })).title).toBe("Explicit title")
|
||||
}),
|
||||
)
|
||||
@@ -284,13 +282,9 @@ describe("Session.create", () => {
|
||||
})
|
||||
expect(yield* SessionPending.find(db, forkContext[0].id)).toBeUndefined()
|
||||
expect(yield* SessionPending.find(db, forkContext[1].id)).toBeUndefined()
|
||||
// Fork-copied messages have no admitted event in the fork aggregate, so
|
||||
// reusing their IDs as prompt IDs is conflicting reuse, not a retry.
|
||||
expect(
|
||||
yield* session
|
||||
.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false })
|
||||
.pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "Session.PromptConflictError", messageID: forkContext[0].id })
|
||||
yield* session.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false }),
|
||||
).toMatchObject({ id: forkContext[0].id, type: "user", data: { text: "First" } })
|
||||
|
||||
yield* session.prompt({
|
||||
sessionID: parent.id,
|
||||
@@ -456,32 +450,7 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the current Session projection after projected updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const input = { id, location }
|
||||
const created = yield* session.create(input)
|
||||
|
||||
yield* bus.publish(SessionV1.Event.Updated, {
|
||||
sessionID: id,
|
||||
info: SessionV1.SessionInfo.make({
|
||||
id,
|
||||
slug: "updated",
|
||||
version: "test",
|
||||
projectID: created.projectID,
|
||||
directory: created.location.directory,
|
||||
title: "updated",
|
||||
agent: "build",
|
||||
time: { created: 0, updated: 1 },
|
||||
}),
|
||||
})
|
||||
|
||||
expect(yield* session.create(input)).toMatchObject({ id, agent: "build" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists creation through the existing legacy created event", () =>
|
||||
it.effect("persists creation through the current created event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const { db } = yield* Database.Service
|
||||
@@ -489,7 +458,7 @@ describe("Session.create", () => {
|
||||
|
||||
expect(
|
||||
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
|
||||
).toMatchObject([{ type: Bus.versionedType(SessionV1.Event.Created.type, 1) }])
|
||||
).toMatchObject([{ type: Bus.versionedType(SessionEvent.Created.type, 1) }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -507,7 +476,7 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits legacy creation rows from the Session event stream", () =>
|
||||
it.effect("includes current creation rows in the Session event stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
@@ -521,8 +490,9 @@ describe("Session.create", () => {
|
||||
yield* SessionPending.promote(db, bus, created.id, "steer")
|
||||
|
||||
expect(
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(3), Stream.runCollect)),
|
||||
).toMatchObject([
|
||||
{ durable: { seq: 0 }, type: "session.created" },
|
||||
{
|
||||
durable: { seq: 1 },
|
||||
type: "session.input.admitted",
|
||||
@@ -605,7 +575,7 @@ describe("Session.create", () => {
|
||||
.all()
|
||||
.pipe(Effect.orDie)).map((event) => [event.seq, event.type]),
|
||||
).toEqual([
|
||||
[0, Bus.versionedType(SessionV1.Event.Created.type, 1)],
|
||||
[0, Bus.versionedType(SessionEvent.Created.type, 1)],
|
||||
[1, Bus.versionedType(SessionEvent.InputAdmitted.type, 1)],
|
||||
[2, Bus.versionedType(SessionEvent.InputPromoted.type, 1)],
|
||||
])
|
||||
@@ -618,7 +588,7 @@ describe("Session.create", () => {
|
||||
const session = yield* Session.Service
|
||||
const event = yield* Bus.Service
|
||||
const defect = new Error("unrelated projector defect")
|
||||
yield* event.project(SessionV1.Event.Created, () => Effect.die(defect))
|
||||
yield* event.project(SessionEvent.Created, () => Effect.die(defect))
|
||||
|
||||
expect(yield* session.create({ id, location }).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
}),
|
||||
@@ -672,7 +642,7 @@ describe("Session.create", () => {
|
||||
|
||||
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
||||
expect(
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
|
||||
}),
|
||||
)
|
||||
@@ -704,7 +674,9 @@ describe("Session.create", () => {
|
||||
yield* session.switchModel({ sessionID: created.id, model })
|
||||
|
||||
expect(yield* session.get(created.id)).toMatchObject({ model })
|
||||
const bus = Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect))
|
||||
const bus = Array.from(
|
||||
yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect),
|
||||
)
|
||||
expect(bus).toMatchObject([{ type: "session.model.selected" }])
|
||||
expect(bus[0]?.data).toEqual({ sessionID: created.id, model })
|
||||
}),
|
||||
|
||||
@@ -56,7 +56,6 @@ const projects = Layer.succeed(
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const permission = Layer.succeed(
|
||||
|
||||
@@ -23,7 +23,6 @@ const projects = Layer.succeed(
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
@@ -41,17 +40,15 @@ describe("Session.log", () => {
|
||||
it.effect("replays public session events and marks synced at the aggregate watermark", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: created.id, title: "session.renamed" })
|
||||
|
||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
|
||||
const watermark = (yield* bus.sequences([created.id])).get(created.id)
|
||||
|
||||
// Session creation commits a non-public durable event, so the marker's
|
||||
// seq covers more of the aggregate than the public events emitted.
|
||||
expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"])
|
||||
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark })
|
||||
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("Session.move", () => {
|
||||
it.effect("moves a session whose source directory no longer exists", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const destination = AbsolutePath.make(tmp.path)
|
||||
const created = yield* session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "deleted")) }),
|
||||
})
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(destination)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -17,7 +17,6 @@ import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { fromRow } from "@opencode-ai/core/session/info"
|
||||
@@ -126,34 +125,6 @@ describe("SessionProjector", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("folds live compaction deltas into running memory state", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = {
|
||||
messages: [
|
||||
SessionMessage.CompactionRunning.make({
|
||||
id: SessionMessage.ID.make("msg_compaction"),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: "manual",
|
||||
summary: "partial ",
|
||||
recent: "recent",
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
}
|
||||
yield* SessionMessageUpdater.update(
|
||||
SessionMessageUpdater.memory(state),
|
||||
SessionEvent.Compaction.Delta.make({
|
||||
id: Event.ID.make("evt_delta"),
|
||||
type: "session.compaction.delta",
|
||||
created,
|
||||
data: { sessionID, text: "summary" },
|
||||
}),
|
||||
)
|
||||
expect(state.messages[0]).toMatchObject({ status: "running", summary: "partial summary", recent: "recent" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects staged, cleared, and committed reverts", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
@@ -550,31 +521,6 @@ describe("SessionProjector", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const stale = SessionMessage.Assistant.make({
|
||||
id: SessionMessage.ID.make("msg_assistant_stale"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
model,
|
||||
content: [],
|
||||
time: { created },
|
||||
})
|
||||
const completed = SessionMessage.Assistant.make({
|
||||
id: SessionMessage.ID.make("msg_assistant_completed"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
model,
|
||||
content: [],
|
||||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* SessionMessageUpdater.memory({ messages: [stale, completed] }).getCurrentAssistant(),
|
||||
).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
@@ -553,6 +553,47 @@ describe("Session.prompt", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles an exact retry from the promoted message without admission history", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
|
||||
const first = yield* session.prompt(input)
|
||||
yield* SessionPending.promote(db, bus, sessionID, "steer")
|
||||
yield* db
|
||||
.delete(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const retried = yield* session.prompt(input)
|
||||
|
||||
expect(retried).toMatchObject({ id: first.id, type: "user", data: { text: first.data.text } })
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||
{ id: messageID, type: "user", text: "Fix the failing tests" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores delivery when retrying a promoted message", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
|
||||
yield* session.prompt(input)
|
||||
yield* SessionPending.promote(db, bus, sessionID, "steer")
|
||||
|
||||
const retried = yield* session.prompt({ ...input, delivery: "queue" })
|
||||
|
||||
expect(retried).toMatchObject({ id: messageID, type: "user", data: { text: input.text } })
|
||||
expect(yield* admitted(messageID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("wakes execution when an exact prompt retry recovers a committed message", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
||||
@@ -19,7 +19,6 @@ const projects = Layer.succeed(
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
|
||||
@@ -1180,7 +1180,7 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forks instruction values at the selected message instead of the parent's latest state", () =>
|
||||
it.effect("seeds a fork with the parent's newest instruction values", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* runPrompt(session, "First")
|
||||
@@ -1197,14 +1197,16 @@ describe("SessionRunnerLLM", () => {
|
||||
.where(eq(InstructionStateTable.session_id, forked.id))
|
||||
.get(),
|
||||
).toMatchObject({
|
||||
initial_values: { "test/context": Instructions.hash("Changed context") },
|
||||
current_values: { "test/context": Instructions.hash("Changed context") },
|
||||
initial_values: { "test/context": Instructions.hash("Latest context") },
|
||||
current_values: { "test/context": Instructions.hash("Latest context") },
|
||||
})
|
||||
yield* session.prompt({ sessionID: forked.id, text: "Forked", resume: false })
|
||||
yield* session.resume(forked.id)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"])
|
||||
expect(systemTexts(requests.at(-1)!)).toContain("Latest context")
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Latest context"])
|
||||
// Copied history keeps the frozen chronological update; no new update is emitted.
|
||||
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
|
||||
expect(systemTexts(requests.at(-1)!)).not.toContain("Latest context")
|
||||
|
||||
const { db } = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
@@ -1263,7 +1265,7 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rebuilds a missing instruction cache without admitting another delta", () =>
|
||||
it.effect("re-establishes a fresh baseline when instruction state is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
@@ -1277,13 +1279,15 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||
expect(messageRoles(requests[0])).toEqual(["user", "user"])
|
||||
// The projected row is authoritative: a missing row admits a fresh baseline
|
||||
// instead of rebuilding from durable events.
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.select({ data: EventTable.data })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.instructions.updated.2"))
|
||||
.all(),
|
||||
).toHaveLength(1)
|
||||
).toHaveLength(2)
|
||||
expect(yield* db.select().from(InstructionStateTable).get()).toMatchObject({
|
||||
initial_values: { "test/context": Instructions.hash("Initial context") },
|
||||
current_values: { "test/context": Instructions.hash("Initial context") },
|
||||
@@ -1310,7 +1314,10 @@ describe("SessionRunnerLLM", () => {
|
||||
])
|
||||
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
||||
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
||||
// The chronological update is a durable client-visible system message.
|
||||
const messages = yield* session.messages({ sessionID })
|
||||
expect(messages).toHaveLength(3)
|
||||
expect(messages[1]).toMatchObject({ type: "system", text: "Changed context" })
|
||||
const { db } = yield* Database.Service
|
||||
const updates = yield* db
|
||||
.select({ data: EventTable.data })
|
||||
@@ -1327,9 +1334,10 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(updates[1]?.data).toEqual({
|
||||
sessionID,
|
||||
delta: { "test/context": Instructions.hash("Changed context") },
|
||||
text: "Changed context",
|
||||
})
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1596,7 +1604,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
||||
{ type: "text", text: "System context source removed: test/context" },
|
||||
])
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1708,12 +1716,14 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
|
||||
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
||||
"user",
|
||||
"system",
|
||||
"user",
|
||||
"model-switched",
|
||||
"system",
|
||||
"user",
|
||||
])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(4)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(6)
|
||||
yield* runPrompt(session, "Fourth")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -117,9 +117,6 @@ describe("Snapshot", () => {
|
||||
RelativePath.make("scope/tracked.txt"),
|
||||
])
|
||||
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||
const preview = yield* snapshot.preview({ files: plan, context: 1 })
|
||||
expect(preview).toHaveLength(1)
|
||||
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
|
||||
yield* snapshot.restore({ files: plan })
|
||||
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
|
||||
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
|
||||
@@ -185,36 +182,6 @@ describe("Snapshot", () => {
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
|
||||
await initGit(project)
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.capture()
|
||||
expect(before).toBeDefined()
|
||||
if (!before) return
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "two\n")
|
||||
await fs.writeFile(path.join(project, "unrelated.txt"), "keep\n")
|
||||
})
|
||||
yield* snapshot.checkout(before)
|
||||
expect(yield* read(path.join(project, "tracked.txt"))).toBe("one\n")
|
||||
expect(yield* read(path.join(project, "unrelated.txt"))).toBe("keep\n")
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function snapshotLayer(data: string, directory: string) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+271
-3193
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user