mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 03:19:52 +00:00
Merge remote-tracking branch 'origin/dev' into mobile-home-layout
This commit is contained in:
commit
43000a15d3
451 changed files with 22520 additions and 10080 deletions
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
|
|
@ -65,7 +65,7 @@ jobs:
|
|||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
|
||||
run: GITHUB_ACTIONS=false bun turbo test
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
|
|
@ -99,7 +99,8 @@ jobs:
|
|||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
|
||||
node-version: "24.15"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ const table = sqliteTable("session", {
|
|||
|
||||
## Testing
|
||||
|
||||
- Avoid mocks as much as possible
|
||||
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ const table = sqliteTable("session", {
|
|||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
|
||||
|
|
|
|||
32
CONTEXT.md
32
CONTEXT.md
|
|
@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
|
|||
_Avoid_: System update, system notification, raw text diff
|
||||
|
||||
**Context Epoch**:
|
||||
The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
|
||||
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
|
||||
|
||||
**Baseline System Context**:
|
||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||
|
|
@ -39,6 +39,18 @@ An expected temporary inability to observe a **Context Source** value; the runti
|
|||
**Safe Provider-Turn Boundary**:
|
||||
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
|
||||
|
||||
**Admitted Prompt**:
|
||||
A durable user input accepted into the Session inbox but not yet included in **Session History**.
|
||||
|
||||
**Prompt Promotion**:
|
||||
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
||||
|
||||
**Provider Turn**:
|
||||
One request to a model provider and the response projected from that request.
|
||||
|
||||
**Session Drain**:
|
||||
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||
|
||||
**Model Tool Output**:
|
||||
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
||||
|
||||
|
|
@ -67,6 +79,11 @@ The host-supplied environment overlay applied by the server when creating a PTY,
|
|||
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
|
||||
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
|
||||
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
|
||||
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
|
||||
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
|
||||
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
|
||||
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
|
||||
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
||||
|
|
@ -75,31 +92,28 @@ The host-supplied environment overlay applied by the server when creating a PTY,
|
|||
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
|
||||
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
|
||||
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
|
||||
- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
|
||||
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
|
||||
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
|
||||
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
|
||||
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
|
||||
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
|
||||
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
|
||||
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
|
||||
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
|
||||
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
|
||||
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
|
||||
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||
- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent.
|
||||
- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline.
|
||||
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
|
||||
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
|
||||
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
|
||||
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
||||
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||
- A **Context Epoch** begins with one immutable **Baseline System Context**.
|
||||
- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**.
|
||||
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
|
||||
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
|
||||
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
|
||||
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
|
||||
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||
|
|
|
|||
86
bun.lock
86
bun.lock
|
|
@ -29,7 +29,7 @@
|
|||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
|
|
@ -111,7 +111,7 @@
|
|||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
|
|
@ -147,7 +147,7 @@
|
|||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
|
|
@ -174,7 +174,7 @@
|
|||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
|
|
@ -196,7 +196,7 @@
|
|||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
|
@ -220,7 +220,7 @@
|
|||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
|
|
@ -240,7 +240,7 @@
|
|||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
|
|
@ -276,6 +276,7 @@
|
|||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
|
|
@ -331,7 +332,7 @@
|
|||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
|
|
@ -385,7 +386,7 @@
|
|||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
|
|
@ -399,7 +400,7 @@
|
|||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
|
|
@ -411,7 +412,7 @@
|
|||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
|
|
@ -442,7 +443,7 @@
|
|||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
|
|
@ -458,10 +459,10 @@
|
|||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
|
|
@ -472,12 +473,12 @@
|
|||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.74",
|
||||
"effect": "4.0.0-beta.83",
|
||||
},
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
|
|
@ -495,7 +496,7 @@
|
|||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
|
|
@ -623,8 +624,9 @@
|
|||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
|
|
@ -661,7 +663,7 @@
|
|||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
|
|
@ -676,7 +678,7 @@
|
|||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
|
|
@ -690,7 +692,7 @@
|
|||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
|
|
@ -703,7 +705,7 @@
|
|||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
|
|
@ -736,7 +738,7 @@
|
|||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
|
|
@ -755,7 +757,7 @@
|
|||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
|
|
@ -795,7 +797,7 @@
|
|||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
|
|
@ -822,7 +824,7 @@
|
|||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
|
|
@ -871,7 +873,7 @@
|
|||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
|
@ -935,9 +937,9 @@
|
|||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.83",
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
|
|
@ -973,7 +975,7 @@
|
|||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.74",
|
||||
"effect": "4.0.0-beta.83",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
|
@ -1328,13 +1330,13 @@
|
|||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.74", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.74" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-flpyqLPyr+THSe6ZCGRZl6hi+FqxbIXNSkslKGiRJAjbPabam9mSp7R3aC8biIMt6xE4Fd0LNfo4p2GplUkm2Q=="],
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.74", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.74", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74", "ioredis": "^5.7.0" } }, "sha512-/W16mKqxvhWINLjufzc0log1sl57exXQfwd+em398/zKCbmU3S7snXTDMN6w0ju2TtgK35qrsoGBXEochij6Sg=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83", "ioredis": "^5.7.0" } }, "sha512-RmpVGu/+X/Bif3/g1Rzj8oFzTOknoVB3yHCa0b179vytPpKe+Kj9ZwKNcAnKWqHUDkbSPBq1Ca60mvOHr2/+LQ=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.74", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-C6C2hXixNcZXLaFF2u7B/FtOsqpdY7luaPuiGFBJza0P7EnYDkwaT3kB6lv7l/qctmkADc24qOsSCWIKRbC4jg=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.83", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-+yr/+PJmKTgmJq1QOINSBPgLu7Cjc4CZcotBXnGjyDEizOmimFgTkN2B8PBJAKIKUWYWfobjXqC+58/VhhPKAw=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-RVMRVY7NhSoAp9cAAyy4TT6dt6NNZjOpWeqticoho9HNBukxQSUcu/kjcz4Iq9eoQfXadmepu8kZqtdZULM/fg=="],
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
|
|
@ -3366,7 +3368,7 @@
|
|||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
||||
"effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
|
|
@ -5898,6 +5900,10 @@
|
|||
|
||||
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
||||
|
||||
"@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
||||
|
||||
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
||||
|
||||
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||
|
||||
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
|
@ -6700,6 +6706,10 @@
|
|||
|
||||
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
||||
|
||||
"@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-LOxTad/iCquvJyonFOcz6/rDTPNDmwyBnykhWZJ5GC4=",
|
||||
"aarch64-linux": "sha256-iO+0vYhp+2x6ACmh5lQJ/2Ac4uZTqRZE/KhG3u0o6D8=",
|
||||
"aarch64-darwin": "sha256-tpBydRbrJ+4QxmkGUt/BhME8q6ysCW/CXrsNshYgqDU=",
|
||||
"x86_64-darwin": "sha256-QQcI6SK7WJ7dSkX6xZuSQPoUdwfoCaimVgoHCnrO0wY="
|
||||
"x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=",
|
||||
"aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=",
|
||||
"aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=",
|
||||
"x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs="
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@
|
|||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.83",
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.74",
|
||||
"effect": "4.0.0-beta.83",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
## Priorities
|
||||
|
||||
- Prioritise, in this order: stability, simplicity, performance.
|
||||
- Before changing session or timeline code, record a production benchmark baseline and compare it after the change.
|
||||
|
||||
## Debugging
|
||||
|
||||
- NEVER try to restart the app, or the server process, EVER.
|
||||
|
|
|
|||
13
packages/app/e2e/performance/AGENTS.md
Normal file
13
packages/app/e2e/performance/AGENTS.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
- Prioritize stability, then simplicity, then measurement overhead.
|
||||
- Use Playwright for scenario control, isolation, and completion checks.
|
||||
- Use Chrome Performance traces for generic browser profiling.
|
||||
- Use Electron `contentTracing` for packaged multi-process profiling.
|
||||
- Keep custom probes only for product-specific measurements.
|
||||
- Do not duplicate measurements across the harness, probes, and traces.
|
||||
- Run benchmarks serially to avoid cross-test contention.
|
||||
- Run benchmarks against production builds.
|
||||
- Keep detailed profiling opt-in when it changes workload behavior.
|
||||
- Preserve raw diagnostic data or use lossless representations.
|
||||
- Do not enforce machine-dependent performance thresholds.
|
||||
- Assert scenario completion and metric collection only.
|
||||
- Keep normal test discovery free of manual benchmarks.
|
||||
77
packages/app/e2e/performance/README.md
Normal file
77
packages/app/e2e/performance/README.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# Manual app performance suite
|
||||
|
||||
The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
|
||||
|
||||
Run the suite explicitly from `packages/app`:
|
||||
|
||||
```sh
|
||||
bun run test:bench
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:PLAYWRIGHT_WORKERS = "1"
|
||||
bun run test:bench
|
||||
```
|
||||
|
||||
The suite contains:
|
||||
|
||||
- cold and hot session-tab timing
|
||||
- cached session repaint and mutation tracing
|
||||
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
|
||||
|
||||
All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
|
||||
|
||||
New benchmarks should look like normal Playwright tests:
|
||||
|
||||
```ts
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
|
||||
benchmark("measures one interaction", async ({ page, report }) => {
|
||||
// Only scenario-specific setup and interaction belong here.
|
||||
report({ durationMs: 42 })
|
||||
})
|
||||
```
|
||||
|
||||
The fixture requires every benchmark to call `report()`, automatically names and closes traces, captures navigation history, attaches that history when a test fails, and emits metrics as a consistent `BENCHMARK` JSON line.
|
||||
|
||||
```text
|
||||
BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
|
||||
```
|
||||
|
||||
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
|
||||
|
||||
This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
|
||||
|
||||
These Playwright benchmarks profile the shared app renderer in Chromium. A future packaged Electron benchmark that needs main-process and multi-process attribution should use Electron's official [`contentTracing`](https://www.electronjs.org/docs/latest/api/content-tracing/) API rather than extending this renderer harness with bespoke process instrumentation.
|
||||
|
||||
CPU and high-volume visual profiling are disabled by default. Set `TIMELINE_CPU_PROFILE=1` to enable both, or additionally set `TIMELINE_VISUAL_PROFILE=0` for CPU-only profiling.
|
||||
|
||||
The streaming scenario's 30x CPU throttle is a deterministic stress profile, not a simulated end-user device.
|
||||
|
||||
Benchmarks do not assert machine-dependent performance budgets. Streaming processes 160 deltas by default and reports renderer-observed completion time, throughput, RAF callback-gap distributions, frame-budget equivalents, and long tasks through final geometry settlement. Delta count and delivery batch are included in result context when overridden. These are main-thread callback diagnostics, not compositor presentation or dropped-frame measurements. Visual-only and geometry metrics are `null` when their probes are disabled. Tab metrics describe sampled DOM observations. Assertions verify scenario and metric collection completion. Repeated repaint states are run-length grouped, but every original observation timestamp is retained alongside raw mutation batches and layout shifts.
|
||||
|
||||
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
|
||||
|
||||
## Chrome traces
|
||||
|
||||
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
|
||||
|
||||
```sh
|
||||
OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-performance-traces \
|
||||
bunx playwright test --config e2e/performance/playwright.config.ts \
|
||||
timeline/session-tab-switch-benchmark.spec.ts
|
||||
```
|
||||
|
||||
The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
|
||||
|
||||
Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
|
||||
|
||||
```sh
|
||||
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
|
||||
```
|
||||
|
||||
INP analysis requires a trace with a supported navigation/interaction insight. Selector statistics require a trace captured with `OPENCODE_PERFORMANCE_SELECTOR_TRACE=1`.
|
||||
|
||||
`e2e/performance/playwright.uncapped.config.ts` disables Chromium frame-rate limiting for explicit uncapped diagnostics. Native product benchmarks should use the default Playwright configuration.
|
||||
144
packages/app/e2e/performance/benchmark.ts
Normal file
144
packages/app/e2e/performance/benchmark.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test"
|
||||
import { startChromeTrace } from "./chrome-trace"
|
||||
|
||||
type BenchmarkFixtures = {
|
||||
report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
|
||||
reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
|
||||
benchmarkResult: void
|
||||
}
|
||||
|
||||
export type PerformancePageDiagnostics = {
|
||||
navigations: string[]
|
||||
stop: () => Promise<string | undefined>
|
||||
}
|
||||
|
||||
const pages = new WeakMap<Page, PerformancePageDiagnostics>()
|
||||
|
||||
export const benchmark = base.extend<BenchmarkFixtures>({
|
||||
reportState: async ({}, use) => use({}),
|
||||
report: async ({ reportState }, use) => {
|
||||
await use((metrics, context = {}) => {
|
||||
if (reportState.payload) throw new Error("Benchmark reported metrics more than once")
|
||||
reportState.payload = { metrics, context }
|
||||
})
|
||||
},
|
||||
benchmarkResult: [
|
||||
async ({ reportState }, use, testInfo) => {
|
||||
await use()
|
||||
const missing = !reportState.payload
|
||||
console.log(
|
||||
`BENCHMARK ${JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
|
||||
name: benchmarkName(testInfo),
|
||||
status: missing ? "failed" : testInfo.status,
|
||||
expectedStatus: testInfo.expectedStatus,
|
||||
retry: testInfo.retry,
|
||||
repeatEachIndex: testInfo.repeatEachIndex,
|
||||
context: {
|
||||
project: testInfo.project.name,
|
||||
platform: process.platform,
|
||||
...reportState.payload?.context,
|
||||
},
|
||||
metrics: reportState.payload?.metrics ?? null,
|
||||
error: missing ? "Benchmark did not report metrics" : undefined,
|
||||
})}`,
|
||||
)
|
||||
if (missing && testInfo.status === testInfo.expectedStatus)
|
||||
throw new Error(`Benchmark did not report metrics: ${benchmarkName(testInfo)}`)
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
page: async ({ page }, use, testInfo) => {
|
||||
const name = benchmarkName(testInfo)
|
||||
const diagnostics = await observePerformancePage(page, name)
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
try {
|
||||
await reportPerformancePage(name, diagnostics, testInfo)
|
||||
} finally {
|
||||
if (testInfo.status !== testInfo.expectedStatus) {
|
||||
await testInfo.attach("performance-navigations", {
|
||||
body: JSON.stringify(diagnostics.navigations, null, 2),
|
||||
contentType: "application/json",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function benchmarkName(testInfo: TestInfo) {
|
||||
return testInfo.titlePath.slice(1).join(" > ")
|
||||
}
|
||||
|
||||
export { expect }
|
||||
|
||||
async function observePerformancePage(page: Page, name: string) {
|
||||
const navigations: string[] = []
|
||||
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
|
||||
if (frame === page.mainFrame()) navigations.push(frame.url())
|
||||
}
|
||||
page.on("framenavigated", onNavigation)
|
||||
const stopTrace = await startChromeTrace(page, name).catch((error) => {
|
||||
page.off("framenavigated", onNavigation)
|
||||
throw error
|
||||
})
|
||||
let stopping: Promise<string | undefined> | undefined
|
||||
const diagnostics: PerformancePageDiagnostics = {
|
||||
navigations,
|
||||
stop() {
|
||||
page.off("framenavigated", onNavigation)
|
||||
return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
|
||||
},
|
||||
}
|
||||
pages.set(page, diagnostics)
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
export async function withBenchmarkPage<T>(
|
||||
browser: Browser,
|
||||
name: string,
|
||||
run: (page: Page) => Promise<T>,
|
||||
testInfo?: TestInfo,
|
||||
) {
|
||||
const context = await browser.newContext()
|
||||
try {
|
||||
const page = await context.newPage()
|
||||
const diagnostics = await observePerformancePage(page, name)
|
||||
try {
|
||||
return await run(page)
|
||||
} finally {
|
||||
await reportPerformancePage(name, diagnostics, testInfo)
|
||||
}
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function reportPerformancePage(name: string, diagnostics: PerformancePageDiagnostics, testInfo?: TestInfo) {
|
||||
const trace = await diagnostics.stop()
|
||||
console.log(
|
||||
`BENCHMARK_PAGE ${JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
|
||||
name,
|
||||
test: testInfo ? benchmarkName(testInfo) : undefined,
|
||||
retry: testInfo?.retry,
|
||||
repeatEachIndex: testInfo?.repeatEachIndex,
|
||||
context: {
|
||||
platform: process.platform,
|
||||
trace,
|
||||
selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
|
||||
},
|
||||
navigations: diagnostics.navigations,
|
||||
})}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function benchmarkDiagnostics(page: Page) {
|
||||
const diagnostics = pages.get(page)
|
||||
if (!diagnostics) throw new Error("Performance diagnostics are not installed for this page")
|
||||
return diagnostics
|
||||
}
|
||||
95
packages/app/e2e/performance/chrome-trace.ts
Normal file
95
packages/app/e2e/performance/chrome-trace.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { CDPSession, Page } from "@playwright/test"
|
||||
import path from "node:path"
|
||||
import { mkdir, open, rename } from "node:fs/promises"
|
||||
import { Buffer } from "node:buffer"
|
||||
import { createHash, randomUUID } from "node:crypto"
|
||||
|
||||
const categories = [
|
||||
"-*",
|
||||
"devtools.timeline",
|
||||
"v8.execute",
|
||||
"disabled-by-default-devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline.frame",
|
||||
"toplevel",
|
||||
"blink.console",
|
||||
"blink.user_timing",
|
||||
"latencyInfo",
|
||||
"disabled-by-default-devtools.timeline.stack",
|
||||
"disabled-by-default-v8.cpu_profiler",
|
||||
]
|
||||
|
||||
export async function startChromeTrace(page: Page, name: string) {
|
||||
const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
|
||||
if (!directory) return
|
||||
|
||||
const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
|
||||
const file = await prepareChromeTrace(directory, name, selectors)
|
||||
const session = await page.context().newCDPSession(page)
|
||||
try {
|
||||
await session.send("Tracing.start", {
|
||||
transferMode: "ReturnAsStream",
|
||||
traceConfig: {
|
||||
excludedCategories: categories
|
||||
.filter((category) => category.startsWith("-"))
|
||||
.map((category) => category.slice(1)),
|
||||
includedCategories: [
|
||||
...categories.filter((category) => !category.startsWith("-")),
|
||||
...(selectors
|
||||
? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
await Promise.allSettled([session.detach()])
|
||||
throw error
|
||||
}
|
||||
let stopping: Promise<string> | undefined
|
||||
|
||||
return () =>
|
||||
(stopping ??= (async () => {
|
||||
try {
|
||||
const complete = new Promise<{ stream?: string; dataLossOccurred: boolean }>((resolve) =>
|
||||
session.once("Tracing.tracingComplete", resolve),
|
||||
)
|
||||
await session.send("Tracing.end")
|
||||
const result = await complete
|
||||
if (!result.stream) throw new Error(`Chrome trace stream missing: ${file}`)
|
||||
const partial = `${file}.partial`
|
||||
await writeProtocolStream(session, result.stream, partial)
|
||||
if (result.dataLossOccurred) throw new Error(`Chrome trace lost data; partial capture retained: ${partial}`)
|
||||
await rename(partial, file)
|
||||
return file
|
||||
} finally {
|
||||
await Promise.allSettled([session.detach()])
|
||||
}
|
||||
})())
|
||||
}
|
||||
|
||||
export async function prepareChromeTrace(
|
||||
directory: string,
|
||||
name: string,
|
||||
selectors: boolean,
|
||||
nonce = randomUUID().slice(0, 8),
|
||||
) {
|
||||
await mkdir(directory, { recursive: true })
|
||||
const run = process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual"
|
||||
const hash = createHash("sha256").update(name).digest("hex").slice(0, 8)
|
||||
return path.join(
|
||||
directory,
|
||||
`${run}-${name.replace(/[^a-zA-Z0-9_-]/g, "-")}-${hash}-${nonce}${selectors ? "-selectors" : ""}.json`,
|
||||
)
|
||||
}
|
||||
|
||||
async function writeProtocolStream(session: CDPSession, handle: string, file: string) {
|
||||
const output = await open(file, "wx")
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await session.send("IO.read", { handle })
|
||||
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
|
||||
if (chunk.eof) break
|
||||
}
|
||||
} finally {
|
||||
await Promise.allSettled([output.close(), session.send("IO.close", { handle })])
|
||||
}
|
||||
}
|
||||
20
packages/app/e2e/performance/playwright.config.ts
Normal file
20
packages/app/e2e/performance/playwright.config.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import config from "../../playwright.config"
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
|
||||
process.env.PLAYWRIGHT_SERVER_PORT = String(port)
|
||||
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
|
||||
|
||||
export default {
|
||||
...config,
|
||||
testDir: ".",
|
||||
testIgnore: "unit/**",
|
||||
outputDir: "../test-results/performance",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
|
||||
webServer: {
|
||||
...config.webServer,
|
||||
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
}
|
||||
13
packages/app/e2e/performance/playwright.uncapped.config.ts
Normal file
13
packages/app/e2e/performance/playwright.uncapped.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import config from "./playwright.config"
|
||||
|
||||
export default {
|
||||
...config,
|
||||
outputDir: "../test-results/performance-uncapped",
|
||||
reporter: [["html", { outputFolder: "../playwright-report/performance-uncapped", open: "never" }], ["line"]],
|
||||
use: {
|
||||
...config.use,
|
||||
launchOptions: {
|
||||
args: ["--disable-frame-rate-limit", "--disable-gpu-vsync"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { benchmark, expect } from "../benchmark"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import {
|
||||
collectCachedRepaintTrace,
|
||||
compressCachedRepaintTrace,
|
||||
installCachedRepaintProbe,
|
||||
waitForCachedRepaintWindow,
|
||||
} from "./session-tab-repaint-probe"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
import {
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
mockStressTimeline,
|
||||
stressSessionHref,
|
||||
} from "./timeline-test-helpers"
|
||||
|
||||
benchmark("samples cached session repaint after the click", async ({ page, report }) => {
|
||||
benchmark.setTimeout(120_000)
|
||||
await mockStressTimeline(page)
|
||||
await installStressSessionTabs(page)
|
||||
await installTimelineSettings(page)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
||||
await page
|
||||
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`)
|
||||
.first()
|
||||
.click()
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
|
||||
await installCachedRepaintProbe(page, {
|
||||
targetHref: stressSessionHref(fixture.targetID),
|
||||
destination: fixture.messages[fixture.targetID].map((message) => message.info.id),
|
||||
source: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
||||
last: fixture.expected.targetMessageIDs.at(-1)!,
|
||||
windowMs: 1_000,
|
||||
})
|
||||
|
||||
await page
|
||||
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`)
|
||||
.first()
|
||||
.click()
|
||||
await Promise.all([expectSessionTitle(page, fixture.expected.targetTitle), waitForCachedRepaintWindow(page, 1_000)])
|
||||
const result = await collectCachedRepaintTrace(page)
|
||||
report(compressCachedRepaintTrace(result))
|
||||
expect(result.samples.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
import type { Page } from "@playwright/test"
|
||||
|
||||
type CachedRepaintTrace = {
|
||||
timeOriginEpochMs: number
|
||||
startedAtPerformanceMs: number
|
||||
samples: {
|
||||
observedAtMs: number
|
||||
root: number | undefined
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
bottomErrorPx: number | undefined
|
||||
last: boolean
|
||||
rows: { key: string | undefined; node: number; top: number; bottom: number }[]
|
||||
mounted: number
|
||||
center: string | undefined
|
||||
destination: string[]
|
||||
source: string[]
|
||||
}[]
|
||||
mutations: { observedAtMs: number; changed: { type: string; node: number }[] }[]
|
||||
shifts: { occurredAtMs: number; value: number }[]
|
||||
windowMs: number
|
||||
running: boolean
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export async function installCachedRepaintProbe(
|
||||
page: Page,
|
||||
input: { targetHref: string; destination: string[]; source: string[]; last: string; windowMs: number },
|
||||
) {
|
||||
await page.evaluate(({ targetHref, destination, source, last, windowMs }) => {
|
||||
const destinationIDs = new Set(destination)
|
||||
const sourceIDs = new Set(source)
|
||||
const nodeIDs = new WeakMap<Node, number>()
|
||||
let nextNodeID = 1
|
||||
const id = (node: Node) => {
|
||||
const current = nodeIDs.get(node)
|
||||
if (current) return current
|
||||
nodeIDs.set(node, nextNodeID)
|
||||
return nextNodeID++
|
||||
}
|
||||
const state: CachedRepaintTrace = {
|
||||
timeOriginEpochMs: performance.timeOrigin,
|
||||
startedAtPerformanceMs: 0,
|
||||
samples: [],
|
||||
mutations: [],
|
||||
shifts: [],
|
||||
windowMs,
|
||||
running: false,
|
||||
stop: () => {},
|
||||
}
|
||||
const recordShifts = (entries: PerformanceEntry[]) => {
|
||||
if (!state.running) return
|
||||
state.shifts.push(
|
||||
...entries
|
||||
.map((entry) => {
|
||||
if (
|
||||
entry.startTime < state.startedAtPerformanceMs ||
|
||||
entry.startTime > state.startedAtPerformanceMs + state.windowMs
|
||||
)
|
||||
return
|
||||
return {
|
||||
occurredAtMs: entry.startTime - state.startedAtPerformanceMs,
|
||||
value: (entry as PerformanceEntry & { value: number }).value,
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is { occurredAtMs: number; value: number } => entry !== undefined),
|
||||
)
|
||||
}
|
||||
const shiftObserver = new PerformanceObserver((entries) => recordShifts(entries.getEntries()))
|
||||
shiftObserver.observe({ type: "layout-shift" })
|
||||
const recordMutations = (entries: MutationRecord[]) => {
|
||||
if (!state.running) return
|
||||
const observedAtMs = performance.now() - state.startedAtPerformanceMs
|
||||
if (observedAtMs > state.windowMs) return
|
||||
const changed = entries.flatMap((entry) => [
|
||||
...[...entry.addedNodes].map((node) => ({ type: "add", node: id(node) })),
|
||||
...[...entry.removedNodes].map((node) => ({ type: "remove", node: id(node) })),
|
||||
])
|
||||
if (changed.length) state.mutations.push({ observedAtMs, changed })
|
||||
}
|
||||
const mutationObserver = new MutationObserver(recordMutations)
|
||||
mutationObserver.observe(document.documentElement, { childList: true, subtree: true })
|
||||
state.stop = () => {
|
||||
recordShifts(shiftObserver.takeRecords())
|
||||
recordMutations(mutationObserver.takeRecords())
|
||||
state.running = false
|
||||
shiftObserver.disconnect()
|
||||
mutationObserver.disconnect()
|
||||
}
|
||||
const sample = () => {
|
||||
if (!state.running) return
|
||||
setTimeout(() => {
|
||||
if (!state.running) return
|
||||
const observedAtMs = performance.now() - state.startedAtPerformanceMs
|
||||
if (observedAtMs > state.windowMs) return
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (root) {
|
||||
const view = root.getBoundingClientRect()
|
||||
const rows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
||||
.map((element) => ({
|
||||
key: element.dataset.timelineKey,
|
||||
node: id(element),
|
||||
rect: element.getBoundingClientRect(),
|
||||
}))
|
||||
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
|
||||
.map((item) => ({
|
||||
key: item.key,
|
||||
node: item.node,
|
||||
top: item.rect.top - view.top,
|
||||
bottom: item.rect.bottom - view.top,
|
||||
}))
|
||||
const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
||||
.filter((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
.map((element) => element.dataset.messageId!)
|
||||
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
||||
state.samples.push({
|
||||
observedAtMs,
|
||||
root: id(root),
|
||||
scrollTop: root.scrollTop,
|
||||
scrollHeight: root.scrollHeight,
|
||||
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
||||
last: messages.includes(last),
|
||||
rows,
|
||||
mounted: root.querySelectorAll("[data-timeline-key]").length,
|
||||
center: document
|
||||
.elementFromPoint(view.left + view.width / 2, view.top + view.height / 2)
|
||||
?.textContent?.slice(0, 80),
|
||||
destination: messages.filter((messageID) => destinationIDs.has(messageID)),
|
||||
source: messages.filter((messageID) => sourceIDs.has(messageID)),
|
||||
})
|
||||
} else {
|
||||
state.samples.push({
|
||||
observedAtMs,
|
||||
root: undefined,
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
bottomErrorPx: undefined,
|
||||
last: false,
|
||||
rows: [],
|
||||
mounted: 0,
|
||||
center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
|
||||
destination: [],
|
||||
source: [],
|
||||
})
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
}
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const link = event.target instanceof Element ? event.target.closest("a") : undefined
|
||||
if (link?.getAttribute("href") !== targetHref) return
|
||||
state.startedAtPerformanceMs = performance.now()
|
||||
state.running = true
|
||||
requestAnimationFrame(sample)
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
)
|
||||
;(window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash = state
|
||||
}, input)
|
||||
}
|
||||
|
||||
export function layoutShiftSample(entry: Pick<PerformanceEntry, "startTime"> & { value: number }, started: number) {
|
||||
if (entry.startTime < started) return
|
||||
return { occurredAtMs: entry.startTime - started, value: entry.value }
|
||||
}
|
||||
|
||||
export async function waitForCachedRepaintWindow(page: Page, durationMs: number) {
|
||||
await page.waitForFunction((durationMs) => {
|
||||
const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash
|
||||
return !!state?.running && performance.now() - state.startedAtPerformanceMs >= durationMs
|
||||
}, durationMs)
|
||||
}
|
||||
|
||||
export async function collectCachedRepaintTrace(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash!
|
||||
state.stop()
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
export function summarizeCachedRepaintTrace(trace: CachedRepaintTrace) {
|
||||
const roots = trace.samples.map((sample) => sample.root)
|
||||
const bottomErrors = trace.samples.flatMap((sample) =>
|
||||
sample.bottomErrorPx === undefined ? [] : [Math.abs(sample.bottomErrorPx)],
|
||||
)
|
||||
const category = (sample: CachedRepaintTrace["samples"][number]) => {
|
||||
if (sample.source.length) return "source"
|
||||
if (sample.root === undefined || sample.rows.length === 0) return "blank"
|
||||
if (!sample.destination.length) return "unknown"
|
||||
if (sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) return "correct"
|
||||
return "wrongDestination"
|
||||
}
|
||||
return {
|
||||
samples: trace.samples.length,
|
||||
durationMs: trace.samples.at(-1)?.observedAtMs ?? 0,
|
||||
firstSampleObservedMs: trace.samples[0]?.observedAtMs,
|
||||
firstSampleCorrect: trace.samples[0] ? category(trace.samples[0]) === "correct" : false,
|
||||
blankSamples: trace.samples.filter((sample) => category(sample) === "blank").length,
|
||||
sourceSamples: trace.samples.filter((sample) => category(sample) === "source").length,
|
||||
wrongDestinationSamples: trace.samples.filter((sample) => category(sample) === "wrongDestination").length,
|
||||
unknownSamples: trace.samples.filter((sample) => category(sample) === "unknown").length,
|
||||
rootChanges: roots.slice(1).filter((root, index) => root !== roots[index]).length,
|
||||
mountedMin: trace.samples.length ? Math.min(...trace.samples.map((sample) => sample.mounted)) : 0,
|
||||
mountedMax: Math.max(...trace.samples.map((sample) => sample.mounted)),
|
||||
maxBottomErrorPx: Math.max(0, ...bottomErrors),
|
||||
mutationBatches: trace.mutations.length,
|
||||
addedNodes: trace.mutations.reduce(
|
||||
(sum, batch) => sum + batch.changed.filter((change) => change.type === "add").length,
|
||||
0,
|
||||
),
|
||||
removedNodes: trace.mutations.reduce(
|
||||
(sum, batch) => sum + batch.changed.filter((change) => change.type === "remove").length,
|
||||
0,
|
||||
),
|
||||
layoutShiftValueSum: trace.shifts.reduce((sum, shift) => sum + shift.value, 0),
|
||||
maxLayoutShiftValue: Math.max(0, ...trace.shifts.map((shift) => shift.value)),
|
||||
}
|
||||
}
|
||||
|
||||
export function compressCachedRepaintTrace(trace: CachedRepaintTrace) {
|
||||
const samples: {
|
||||
observedAtMs: number[]
|
||||
state: Omit<CachedRepaintTrace["samples"][number], "observedAtMs">
|
||||
}[] = []
|
||||
for (const sample of trace.samples) {
|
||||
const { observedAtMs, ...state } = sample
|
||||
const previous = samples.at(-1)
|
||||
if (previous && JSON.stringify(previous.state) === JSON.stringify(state)) {
|
||||
previous.observedAtMs.push(observedAtMs)
|
||||
continue
|
||||
}
|
||||
samples.push({ observedAtMs: [observedAtMs], state })
|
||||
}
|
||||
return {
|
||||
timeOriginEpochMs: trace.timeOriginEpochMs,
|
||||
startedAtPerformanceMs: trace.startedAtPerformanceMs,
|
||||
windowMs: trace.windowMs,
|
||||
summary: summarizeCachedRepaintTrace(trace),
|
||||
samples,
|
||||
mutations: trace.mutations,
|
||||
shifts: trace.shifts,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import type { Page } from "@playwright/test"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
|
||||
|
||||
benchmark("benchmarks cold and hot session tab switching", async ({ browser, report }, testInfo) => {
|
||||
benchmark.setTimeout(180_000)
|
||||
const results = { cold: [] as Result[], hot: [] as Result[] }
|
||||
for (const mode of ["cold", "hot"] as const) {
|
||||
for (let run = 0; run < 5; run++) {
|
||||
results[mode].push(
|
||||
await withBenchmarkPage(browser, `session-tab-switch-${mode}-${run}`, (page) => trial(page, mode), testInfo),
|
||||
)
|
||||
}
|
||||
}
|
||||
report({ results, summary: summarize(results) })
|
||||
})
|
||||
|
||||
async function trial(page: Page, mode: "cold" | "hot") {
|
||||
await mockStressTimeline(page)
|
||||
await installStressSessionTabs(page)
|
||||
if (mode === "hot") {
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
||||
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
} else {
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
}
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
|
||||
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
||||
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
|
||||
const lastID = fixture.expected.targetMessageIDs.at(-1)!
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const result = await measureSessionSwitch(page, {
|
||||
destinationIDs,
|
||||
sourceIDs,
|
||||
lastID,
|
||||
href,
|
||||
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function summarize(results: Record<"cold" | "hot", Result[]>) {
|
||||
const stats = (values: (number | null)[]) => {
|
||||
const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b)
|
||||
return {
|
||||
min: sorted[0] ?? null,
|
||||
median: sorted[Math.floor(sorted.length / 2)] ?? null,
|
||||
max: sorted.at(-1) ?? null,
|
||||
missing: values.length - sorted.length,
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(results).map(([mode, values]) => [
|
||||
mode,
|
||||
{
|
||||
firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)),
|
||||
firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)),
|
||||
stableObservedMs: stats(values.map((value) => value.stableObservedMs)),
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
const href = stressSessionHref(sessionID)
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
export type SessionSwitchSample = {
|
||||
observedAtMs: number
|
||||
destination: string[]
|
||||
source: string[]
|
||||
hasVisibleRows: boolean
|
||||
last: boolean
|
||||
bottomErrorPx?: number
|
||||
}
|
||||
|
||||
export function classifySessionSwitch(samples: SessionSwitchSample[]) {
|
||||
const firstDestination = samples.findIndex((sample) => sample.destination.length > 0)
|
||||
const firstCorrect = samples.findIndex(isCorrectDestination)
|
||||
const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3)))
|
||||
return {
|
||||
firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null,
|
||||
firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null,
|
||||
stableObservedMs: samples[stable + 2]?.observedAtMs ?? null,
|
||||
wrongDestinationSamples: samples
|
||||
.slice(firstDestination)
|
||||
.filter((sample) => sample.destination.length > 0 && !sample.last).length,
|
||||
blankSamples: samples.filter((sample) => !sample.hasVisibleRows).length,
|
||||
unknownSamples: samples.filter(
|
||||
(sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0,
|
||||
).length,
|
||||
sourceSamples: samples.filter((sample) => sample.source.length > 0).length,
|
||||
}
|
||||
}
|
||||
|
||||
export function isCorrectDestination(sample: SessionSwitchSample) {
|
||||
return (
|
||||
sample.destination.length > 0 &&
|
||||
sample.source.length === 0 &&
|
||||
sample.last &&
|
||||
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
|
||||
)
|
||||
}
|
||||
|
||||
export function isStableSessionSwitch(samples: SessionSwitchSample[]) {
|
||||
return samples.length === 3 && samples.every(isCorrectDestination)
|
||||
}
|
||||
|
||||
export function isStableDestination(samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[]) {
|
||||
return (
|
||||
samples.length === 3 && samples.every((sample) => sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import { expect, type Page } from "@playwright/test"
|
||||
import { classifySessionSwitch, isStableDestination, type SessionSwitchSample } from "./session-tab-switch-metrics"
|
||||
|
||||
type SessionSwitchProbe = {
|
||||
samples: SessionSwitchSample[]
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
async function installSessionSwitchProbe(
|
||||
page: Page,
|
||||
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
|
||||
) {
|
||||
await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
|
||||
const destination = new Set(destinationIDs)
|
||||
const source = new Set(sourceIDs)
|
||||
const samples: SessionSwitchSample[] = []
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
setTimeout(() => {
|
||||
if (!running || started === undefined) return
|
||||
const observedAtMs = performance.now() - started
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (root) {
|
||||
const view = root.getBoundingClientRect()
|
||||
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
||||
.filter((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
.map((element) => element.dataset.messageId!)
|
||||
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
||||
samples.push({
|
||||
observedAtMs,
|
||||
destination: visible.filter((id) => destination.has(id)),
|
||||
source: visible.filter((id) => source.has(id)),
|
||||
hasVisibleRows,
|
||||
last: visible.includes(lastID),
|
||||
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
||||
})
|
||||
} else {
|
||||
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false })
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
}
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const link = event.target instanceof Element ? event.target.closest("a") : undefined
|
||||
if (link?.getAttribute("href") !== href) return
|
||||
started = performance.now()
|
||||
requestAnimationFrame(sample)
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
)
|
||||
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
},
|
||||
}
|
||||
}, input)
|
||||
}
|
||||
|
||||
async function waitForStableSessionSwitch(page: Page) {
|
||||
await page.waitForFunction(() => {
|
||||
const samples = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.samples
|
||||
if (!samples) return false
|
||||
return samples.some((_, index) => {
|
||||
const stable = samples.slice(index, index + 3)
|
||||
return (
|
||||
stable.length === 3 &&
|
||||
stable.every(
|
||||
(sample) =>
|
||||
sample.destination.length > 0 &&
|
||||
sample.source.length === 0 &&
|
||||
sample.last &&
|
||||
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function collectSessionSwitchResult(page: Page) {
|
||||
const samples = await page.evaluate(() => {
|
||||
const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
|
||||
probe.stop()
|
||||
return probe.samples
|
||||
})
|
||||
return classifySessionSwitch(samples)
|
||||
}
|
||||
|
||||
export async function measureSessionSwitch(
|
||||
page: Page,
|
||||
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
|
||||
) {
|
||||
const { switch: run, ...probe } = input
|
||||
await installSessionSwitchProbe(page, probe)
|
||||
await run()
|
||||
await waitForStableSessionSwitch(page)
|
||||
return collectSessionSwitchResult(page)
|
||||
}
|
||||
|
||||
export async function waitForStableTimeline(page: Page, lastID: string) {
|
||||
const samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[] = []
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
samples.push(
|
||||
await page.evaluate(
|
||||
(lastID) =>
|
||||
new Promise<Pick<SessionSwitchSample, "last" | "bottomErrorPx">>((resolve) => {
|
||||
requestAnimationFrame(() =>
|
||||
setTimeout(() => {
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (!root) {
|
||||
resolve({ last: false })
|
||||
return
|
||||
}
|
||||
const view = root.getBoundingClientRect()
|
||||
const last = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].some((element) => {
|
||||
if (element.dataset.messageId !== lastID) return false
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
const spacer = root
|
||||
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
||||
?.getBoundingClientRect()
|
||||
resolve({ last, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined })
|
||||
}, 0),
|
||||
)
|
||||
}),
|
||||
lastID,
|
||||
),
|
||||
)
|
||||
return isStableDestination(samples.slice(-3))
|
||||
},
|
||||
{ timeout: 30_000, intervals: [0] },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
|
@ -0,0 +1,488 @@
|
|||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
|
||||
import { expect } from "../benchmark"
|
||||
|
||||
const directory = "C:/OpenCode/TimelineStateRegression"
|
||||
const projectID = "proj_timeline_state_regression"
|
||||
const sessionID = "ses_timeline_state_regression"
|
||||
const userMessageID = "msg_user_regression"
|
||||
const assistantMessageID = "msg_assistant_regression"
|
||||
const editPartID = "prt_0001_edit"
|
||||
export const textPartID = "prt_9999_text"
|
||||
const title = "Timeline collapse state regression"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
const userMessage = {
|
||||
info: {
|
||||
id: userMessageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_user_text",
|
||||
sessionID,
|
||||
messageID: userMessageID,
|
||||
type: "text",
|
||||
text: "Please edit the file.",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const editPart = {
|
||||
id: editPartID,
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
type: "tool",
|
||||
callID: "call_edit_regression",
|
||||
tool: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "src/regression.ts" },
|
||||
output: "Edited src/regression.ts",
|
||||
title: "src/regression.ts",
|
||||
metadata: {
|
||||
filediff: {
|
||||
file: "src/regression.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "export const value = 'before'\n",
|
||||
after: "export const value = 'after'\n",
|
||||
},
|
||||
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
|
||||
},
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
}
|
||||
|
||||
const streamedTextPart = {
|
||||
id: textPartID,
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
type: "text",
|
||||
text: "Streaming added a later assistant text part.",
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
info: {
|
||||
id: assistantMessageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000001000 },
|
||||
parentID: userMessageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
},
|
||||
parts: [editPart],
|
||||
}
|
||||
|
||||
export async function setupTimelineBenchmark(page: Page, options: { historyTurns: number; eventBatch: number }) {
|
||||
const events: EventPayload[] = []
|
||||
let eventBatch = options.eventBatch
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions: [session()],
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
|
||||
userMessage,
|
||||
assistantMessage,
|
||||
],
|
||||
}),
|
||||
events: () => events.splice(0, eventBatch),
|
||||
eventRetry: 16,
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
showSessionProgressBar: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
await page.setViewportSize({ width: 1366, height: 768 })
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expectAppVisible(scroller)
|
||||
return {
|
||||
scroller,
|
||||
text,
|
||||
transport: {
|
||||
enqueue(payload: EventPayload | EventPayload[]) {
|
||||
events.push(...(Array.isArray(payload) ? payload : [payload]))
|
||||
},
|
||||
pendingCount() {
|
||||
return events.length
|
||||
},
|
||||
releaseAll() {
|
||||
eventBatch = events.length
|
||||
},
|
||||
},
|
||||
async scrollToBottom() {
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight
|
||||
})
|
||||
},
|
||||
async waitForStableGeometry() {
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
await page.waitForFunction((partID) => {
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector(`[data-timeline-part-id="${partID}"]`),
|
||||
)
|
||||
if (!root) return false
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const height = root.scrollHeight
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() =>
|
||||
resolve(root.scrollHeight === height && root.scrollHeight - root.clientHeight - root.scrollTop <= 1),
|
||||
),
|
||||
)
|
||||
})
|
||||
}, textPartID)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInitialStreamEvent(deltaCount: number): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
...streamedTextPart,
|
||||
text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
|
||||
return Array.from({ length: deltaCount }, (_, index) => ({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
messageID: assistantMessageID,
|
||||
partID: textPartID,
|
||||
field: "text",
|
||||
delta: streamChunk(index + 1, deltaCount + 1),
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function performanceTurn(index: number) {
|
||||
const suffix = String(index).padStart(4, "0")
|
||||
const userID = `msg_0000_${suffix}_a_user`
|
||||
const assistantID = `msg_0000_${suffix}_b_assistant`
|
||||
const before = historicalSource(index, false)
|
||||
const after = historicalSource(index, true)
|
||||
const parts = [
|
||||
...(index % 5 === 0
|
||||
? [
|
||||
{
|
||||
id: `prt_0000_${suffix}_reasoning`,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "reasoning",
|
||||
text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`,
|
||||
time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: `prt_0000_${suffix}_assistant`,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "text",
|
||||
text: historicalMarkdown(index),
|
||||
},
|
||||
...(index % 8 === 0
|
||||
? [
|
||||
{
|
||||
id: `prt_0000_${suffix}_edit`,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: `call_0000_${suffix}_edit`,
|
||||
tool: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: `src/history-${index}.ts` },
|
||||
output: `Edited src/history-${index}.ts`,
|
||||
title: `src/history-${index}.ts`,
|
||||
metadata: {
|
||||
filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
|
||||
},
|
||||
time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 },
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(index % 12 === 0
|
||||
? [
|
||||
{
|
||||
id: `prt_0000_${suffix}_write`,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: `call_0000_${suffix}_write`,
|
||||
tool: "write",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: `src/generated-${index}.tsx`, content: after },
|
||||
output: `Wrote src/generated-${index}.tsx`,
|
||||
title: `src/generated-${index}.tsx`,
|
||||
metadata: {
|
||||
filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after },
|
||||
},
|
||||
time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 },
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(index % 16 === 0
|
||||
? [
|
||||
{
|
||||
id: `prt_0000_${suffix}_patch`,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: `call_0000_${suffix}_patch`,
|
||||
tool: "apply_patch",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { patchText: realisticPatch(index) },
|
||||
output: "Success. Updated src/components/SessionCard.tsx",
|
||||
title: "src/components/SessionCard.tsx",
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
filePath: "src/components/SessionCard.tsx",
|
||||
relativePath: "src/components/SessionCard.tsx",
|
||||
type: "update",
|
||||
additions: 8,
|
||||
deletions: 3,
|
||||
patch: realisticPatch(index),
|
||||
before,
|
||||
after,
|
||||
},
|
||||
],
|
||||
},
|
||||
time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 },
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
return [
|
||||
{
|
||||
info: {
|
||||
id: userID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1690000000000 + index * 2_000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: `prt_0000_${suffix}_user`,
|
||||
sessionID,
|
||||
messageID: userID,
|
||||
type: "text",
|
||||
text: `Historical prompt ${index}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: assistantID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
|
||||
parentID: userID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
finish: "stop",
|
||||
},
|
||||
parts,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function historicalMarkdown(index: number) {
|
||||
const code = `import { For, Show, createSignal } from "solid-js"
|
||||
|
||||
type SessionRow = { id: string; title: string; active: boolean }
|
||||
|
||||
export function SessionList(props: { rows: SessionRow[] }) {
|
||||
const [selected, setSelected] = createSignal<string>()
|
||||
return (
|
||||
<section aria-label="Sessions">
|
||||
<For each={props.rows}>{(row) => (
|
||||
<button classList={{ active: row.active }} onClick={() => setSelected(row.id)}>
|
||||
<Show when={selected() === row.id} fallback={row.title}>{row.title.toUpperCase()}</Show>
|
||||
</button>
|
||||
)}</For>
|
||||
</section>
|
||||
)
|
||||
}`
|
||||
return `## Session renderer review ${index}
|
||||
|
||||
The active session keeps **semantic row identity** while reconciling measured content. See [Solid documentation](https://docs.solidjs.com/) and the inline \`measureElement(node)\` call.
|
||||
|
||||
| Concern | Current behavior | Verification |
|
||||
| --- | --- | --- |
|
||||
| streaming | appends Markdown blocks | painted frames |
|
||||
| geometry | anchors visible rows | DOM coordinates |
|
||||
| tools | preserves expanded state | keyed remount probe |
|
||||
|
||||
> Long sessions combine Markdown, syntax highlighting, tool output, and asynchronously rendered diffs.
|
||||
|
||||
${index % 4 === 0 ? `\`\`\`tsx\n${code}\n\`\`\`\n\n\`\`\`bash\nbun typecheck\nbun test --preload ./happydom.ts ./src/pages/session\ngit diff --check\n\`\`\`` : "- preserve the viewport anchor\n- avoid replacing stable Markdown nodes\n- process provider deltas without blocking input"}`
|
||||
}
|
||||
|
||||
function historicalSource(index: number, updated: boolean) {
|
||||
const method = updated ? "toLocaleUpperCase(props.locale)" : "toUpperCase()"
|
||||
const limit = updated ? 24 : 20
|
||||
return `import { createMemo, For } from "solid-js"
|
||||
|
||||
type Message = {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
text: string
|
||||
tokens: { input: number; output: number }
|
||||
}
|
||||
|
||||
export function MessageSummary(props: { messages: Message[]; locale: string }) {
|
||||
const visible = createMemo(() => props.messages.filter((message) => message.text.trim()).slice(-${limit}))
|
||||
const total = createMemo(() => visible().reduce((sum, message) => sum + message.tokens.output, 0))
|
||||
return (
|
||||
<article data-session-index="${index}">
|
||||
<header>{total().toLocaleString(props.locale)} output tokens</header>
|
||||
<For each={visible()}>{(message) => <p data-role={message.role}>{message.text.${method}}</p>}</For>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function realisticPatch(index: number) {
|
||||
return `*** Begin Patch
|
||||
*** Update File: src/components/SessionCard.tsx
|
||||
@@
|
||||
-const title = props.session.title.toUpperCase()
|
||||
-const messages = props.messages.slice(-20)
|
||||
+const title = props.session.title.toLocaleUpperCase(props.locale)
|
||||
+const messages = props.messages.filter((message) => message.text.trim()).slice(-24)
|
||||
+const outputTokens = messages.reduce((sum, message) => sum + message.tokens.output, 0)
|
||||
@@
|
||||
- <h2>{title}</h2>
|
||||
+ <h2 data-session-index="${index}">{title}</h2>
|
||||
+ <span>{outputTokens.toLocaleString(props.locale)} output tokens</span>
|
||||
*** End Patch`
|
||||
}
|
||||
|
||||
export function streamChunk(index: number, count: number) {
|
||||
if (index === 0) return `\n\n## Implementation plan\n\nStreaming **bold analysis`
|
||||
if (index === count - 1)
|
||||
return `\n\`\`\`\n\n## Verification\n\n- **Typecheck:** passed\n- **Timeline geometry:** stable\n- **Streaming output:** benchmark-complete <!-- stream-${index} -->`
|
||||
|
||||
const section = Math.floor(index / 18) + 1
|
||||
const fragments = [
|
||||
` continues across three`,
|
||||
` or four word`,
|
||||
` provider deltas and`,
|
||||
` closes in this fragment**. <!-- stream-${index} -->\n\n`,
|
||||
`| Concern | State`,
|
||||
` | Verification |\n|`,
|
||||
` --- | ---`,
|
||||
` | --- |\n|`,
|
||||
` markdown | incremental |`,
|
||||
` painted frames | <!-- stream-${index} -->\n\n`,
|
||||
`\`\`\`tsx\nconst row: SessionRow`,
|
||||
` = rows[index] ??`,
|
||||
` fallback\nconst title =`,
|
||||
` row.title.toLocaleUpperCase(locale)\n`,
|
||||
`const selected = createMemo(()`,
|
||||
` => row.id ===`,
|
||||
` activeID()) // stream-${index}\n`,
|
||||
`// stream-${index}\n\`\`\`\n\n### Iteration ${section}\n\nStreaming **bold analysis`,
|
||||
]
|
||||
return fragments[(index - 1) % fragments.length]!
|
||||
}
|
||||
|
||||
function project() {
|
||||
return {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "timeline-state-regression",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
}
|
||||
}
|
||||
|
||||
function session() {
|
||||
return {
|
||||
id: sessionID,
|
||||
slug: "timeline-state-regression",
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
}
|
||||
}
|
||||
|
||||
function provider() {
|
||||
return {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import {
|
||||
buildInitialStreamEvent,
|
||||
buildStreamDeltaEvents,
|
||||
setupTimelineBenchmark,
|
||||
textPartID,
|
||||
} from "./session-timeline-benchmark.fixture"
|
||||
import { startTimelineProfile } from "./session-timeline-profile"
|
||||
import {
|
||||
collectTimelineStreamMetrics,
|
||||
installTimelineStreamProbe,
|
||||
startTimelineStreamProbe,
|
||||
} from "./session-timeline-stream-probe"
|
||||
|
||||
benchmark.describe("performance: session timeline streaming", () => {
|
||||
benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => {
|
||||
benchmark.setTimeout(480_000)
|
||||
const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30)
|
||||
const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160)
|
||||
const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320)
|
||||
const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1)
|
||||
const minimal = process.env.TIMELINE_MINIMAL === "1"
|
||||
const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1"
|
||||
const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0"
|
||||
const fixture = await setupTimelineBenchmark(page, {
|
||||
historyTurns,
|
||||
eventBatch,
|
||||
})
|
||||
|
||||
fixture.transport.enqueue(buildInitialStreamEvent(deltaCount))
|
||||
const contentStart = performance.now()
|
||||
await expect(fixture.text).toBeVisible()
|
||||
await expect(fixture.text).toContainText("Implementation plan")
|
||||
const initialContentObservedMs = performance.now() - contentStart
|
||||
await fixture.scrollToBottom()
|
||||
await fixture.waitForStableGeometry()
|
||||
|
||||
const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU })
|
||||
await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal })
|
||||
const deltas = buildStreamDeltaEvents(deltaCount)
|
||||
await startTimelineStreamProbe(page)
|
||||
fixture.transport.enqueue(deltas)
|
||||
|
||||
await page.waitForFunction(
|
||||
(finalIndex) =>
|
||||
(
|
||||
window as Window & {
|
||||
__timelineStreamBenchmark?: { applied: { index: number }[] }
|
||||
}
|
||||
).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex),
|
||||
deltaCount,
|
||||
{ timeout: 420_000 },
|
||||
)
|
||||
await expect(fixture.text).toContainText("benchmark-complete")
|
||||
await expect(fixture.text).toContainText("Streaming")
|
||||
await fixture.waitForStableGeometry()
|
||||
const metrics = await collectTimelineStreamMetrics(page, {
|
||||
textPartID,
|
||||
finalIndex: deltaCount,
|
||||
navigations: benchmarkDiagnostics(page).navigations,
|
||||
})
|
||||
const delivered = deltas.length - fixture.transport.pendingCount()
|
||||
await profile.stop()
|
||||
|
||||
report(
|
||||
{
|
||||
endToEndInitialContentObservedMs: initialContentObservedMs,
|
||||
...metrics,
|
||||
deliveredDeltas: delivered,
|
||||
pendingDeltas: fixture.transport.pendingCount(),
|
||||
},
|
||||
{
|
||||
cpuThrottle,
|
||||
profileCPU,
|
||||
profileVisual,
|
||||
minimal,
|
||||
queuedDeltas: deltas.length,
|
||||
historyTurns,
|
||||
eventBatch,
|
||||
},
|
||||
)
|
||||
|
||||
await profile.reset()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import type { CDPSession, Page } from "@playwright/test"
|
||||
|
||||
export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) {
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: options.cpuThrottle })
|
||||
if (options.profileCPU) {
|
||||
await cdp.send("Profiler.enable")
|
||||
await cdp.send("Profiler.setSamplingInterval", { interval: 100 })
|
||||
await cdp.send("Profiler.start")
|
||||
}
|
||||
return {
|
||||
async stop() {
|
||||
if (!options.profileCPU) return
|
||||
const result = await cdp.send("Profiler.stop")
|
||||
const self = new Map<number, number>()
|
||||
result.profile.samples?.forEach((id, index) => {
|
||||
const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000
|
||||
self.set(id, (self.get(id) ?? 0) + duration)
|
||||
})
|
||||
console.log(
|
||||
"timeline cpu profile",
|
||||
JSON.stringify(
|
||||
result.profile.nodes
|
||||
.map((node) => ({
|
||||
function: node.callFrame.functionName || "(anonymous)",
|
||||
url: node.callFrame.url,
|
||||
line: node.callFrame.lineNumber + 1,
|
||||
selfMs: self.get(node.id) ?? 0,
|
||||
}))
|
||||
.filter((node) => node.selfMs > 1)
|
||||
.sort((a, b) => b.selfMs - a.selfMs)
|
||||
.slice(0, 40),
|
||||
),
|
||||
)
|
||||
},
|
||||
async reset() {
|
||||
if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,547 @@
|
|||
import type { Page } from "@playwright/test"
|
||||
|
||||
const STREAM_MARKER_PATTERN = "stream-(\\d+)"
|
||||
const STREAM_FRAGMENT_COUNT = 18
|
||||
|
||||
type TimelineProbeState = {
|
||||
started: number
|
||||
ended: number
|
||||
profileVisual: boolean
|
||||
minimal: boolean
|
||||
frames: number[]
|
||||
frameAt: number[]
|
||||
applied: { at: number; index: number }[]
|
||||
geometry: {
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
distance: number
|
||||
virtualHeight: number
|
||||
headerHeight: number
|
||||
}[]
|
||||
blanks: number
|
||||
longTasks: number[]
|
||||
layoutShifts: number[]
|
||||
visibleMounts: number
|
||||
visibleUnmounts: number
|
||||
visibleRows: Set<Element>
|
||||
visibleSubtreeMounts: string[]
|
||||
visibleSubtreeUnmounts: string[]
|
||||
visibleSubtreeReplacements: number
|
||||
visibleSubtreeDropouts: string[]
|
||||
visibleSubtrees: Map<string, Element>
|
||||
subtreeKeys: WeakMap<Element, string>
|
||||
maxOverlap: number
|
||||
maxGap: number
|
||||
maxPartTopMovement: number
|
||||
previousPartTop: number
|
||||
slowFrames: {
|
||||
duration: number
|
||||
index: number
|
||||
phase: "stream" | "boundary" | "complete" | "unknown"
|
||||
tokenSpans: number
|
||||
blocks: number
|
||||
codeBlocks: number
|
||||
height: number
|
||||
distance: number
|
||||
}[]
|
||||
scroll: {
|
||||
calls: number
|
||||
callNoops: number
|
||||
sameFrameCalls: number
|
||||
assignments: number
|
||||
assignmentNoops: number
|
||||
lastCallFrame: number
|
||||
frame: number
|
||||
}
|
||||
row: HTMLElement
|
||||
markdown: HTMLElement
|
||||
running: boolean
|
||||
previous: number
|
||||
cleanup: () => void
|
||||
start: () => void
|
||||
}
|
||||
|
||||
export async function installTimelineStreamProbe(
|
||||
page: Page,
|
||||
options: { textPartID: string; finalIndex: number; profileVisual: boolean; minimal: boolean },
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ textPartID, finalIndex, profileVisual, minimal, markerPattern, fragmentCount }) => {
|
||||
const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
|
||||
const row = part?.closest<HTMLElement>("[data-timeline-row]")
|
||||
const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
|
||||
const root = part?.closest<HTMLElement>(".scroll-view__viewport")
|
||||
if (!part || !row || !markdown || !root) throw new Error("missing streaming benchmark nodes")
|
||||
const viewport = root.getBoundingClientRect()
|
||||
const state: TimelineProbeState = {
|
||||
started: 0,
|
||||
ended: Infinity,
|
||||
profileVisual,
|
||||
minimal,
|
||||
frames: [],
|
||||
frameAt: [],
|
||||
applied: [],
|
||||
geometry: [],
|
||||
blanks: 0,
|
||||
longTasks: [],
|
||||
layoutShifts: [],
|
||||
visibleMounts: 0,
|
||||
visibleUnmounts: 0,
|
||||
visibleRows: new Set(
|
||||
[...root.querySelectorAll("[data-timeline-key]")].filter((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > viewport.top && rect.top < viewport.bottom
|
||||
}),
|
||||
),
|
||||
visibleSubtreeMounts: [],
|
||||
visibleSubtreeUnmounts: [],
|
||||
visibleSubtreeReplacements: 0,
|
||||
visibleSubtreeDropouts: [],
|
||||
visibleSubtrees: new Map<string, Element>(),
|
||||
subtreeKeys: new WeakMap<Element, string>(),
|
||||
maxOverlap: 0,
|
||||
maxGap: 0,
|
||||
maxPartTopMovement: 0,
|
||||
previousPartTop: part.getBoundingClientRect().top,
|
||||
slowFrames: [],
|
||||
scroll: {
|
||||
calls: 0,
|
||||
callNoops: 0,
|
||||
sameFrameCalls: 0,
|
||||
assignments: 0,
|
||||
assignmentNoops: 0,
|
||||
lastCallFrame: -1,
|
||||
frame: 0,
|
||||
},
|
||||
row,
|
||||
markdown,
|
||||
running: false,
|
||||
previous: 0,
|
||||
cleanup: () => {},
|
||||
start: () => {},
|
||||
}
|
||||
;(window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark = state
|
||||
const scrollTo = Element.prototype.scrollTo
|
||||
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
|
||||
if (profileVisual) {
|
||||
Element.prototype.scrollTo = function (...args) {
|
||||
state.scroll.calls += 1
|
||||
const top = typeof args[0] === "object" ? args[0]?.top : args[1]
|
||||
if (typeof top === "number") {
|
||||
const target = Math.min(top, this.scrollHeight - this.clientHeight)
|
||||
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
|
||||
}
|
||||
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
|
||||
state.scroll.lastCallFrame = state.scroll.frame
|
||||
return scrollTo.apply(this, args)
|
||||
}
|
||||
Object.defineProperty(Element.prototype, "scrollTop", {
|
||||
configurable: true,
|
||||
get: scrollTop.get,
|
||||
set(value) {
|
||||
state.scroll.assignments += 1
|
||||
if (Math.abs(this.scrollTop - value) < 1) state.scroll.assignmentNoops += 1
|
||||
scrollTop.set!.call(this, value)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const recordLongTasks = (entries: PerformanceEntry[]) => {
|
||||
if (!state.running) return
|
||||
state.longTasks.push(
|
||||
...entries
|
||||
.filter((entry) => entry.startTime >= state.started && entry.startTime <= state.ended)
|
||||
.map((entry) => entry.duration),
|
||||
)
|
||||
}
|
||||
const longTaskObserver = new PerformanceObserver((list) => recordLongTasks(list.getEntries()))
|
||||
longTaskObserver.observe({ type: "longtask" })
|
||||
const recordLayoutShifts = (entries: PerformanceEntry[]) => {
|
||||
if (!state.running) return
|
||||
state.layoutShifts.push(
|
||||
...entries
|
||||
.map((entry) => {
|
||||
const shift = entry as LayoutShiftEntry
|
||||
if (shift.startTime < state.started || shift.hadRecentInput) return
|
||||
return shift.value
|
||||
})
|
||||
.filter((value): value is number => value !== undefined),
|
||||
)
|
||||
}
|
||||
const layoutShiftObserver = profileVisual
|
||||
? new PerformanceObserver((list) => recordLayoutShifts(list.getEntries()))
|
||||
: undefined
|
||||
layoutShiftObserver?.observe({ type: "layout-shift", buffered: true })
|
||||
|
||||
const visible = (element: Element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const viewport = root.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return (
|
||||
element.isConnected &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.bottom > viewport.top &&
|
||||
rect.top < viewport.bottom &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
Number(style.opacity) > 0
|
||||
)
|
||||
}
|
||||
const critical = [
|
||||
"[data-timeline-part-id]",
|
||||
'[data-component="edit-content"]',
|
||||
'[data-component="apply-patch-file-diff"]',
|
||||
'[data-component="file"]',
|
||||
'[data-component="markdown-code"]',
|
||||
"[data-markdown-block]",
|
||||
].join(",")
|
||||
const describe = (element: Element) => {
|
||||
const cached = state.subtreeKeys.get(element)
|
||||
if (!element.isConnected && cached) return cached
|
||||
const part = element.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId ?? "unknown"
|
||||
const block = element
|
||||
.closest<HTMLElement>("[data-markdown-key]")
|
||||
?.dataset.markdownKey?.replace(/:(?:code|full|live)$/, "")
|
||||
const component =
|
||||
element.getAttribute("data-component") ?? element.getAttribute("data-markdown-block") ?? element.tagName
|
||||
const key = `${part}:${block ?? "root"}:${component}`
|
||||
state.subtreeKeys.set(element, key)
|
||||
return key
|
||||
}
|
||||
const recordMutations = (records: MutationRecord[]) => {
|
||||
if (!state.running) return
|
||||
records.forEach((record) => {
|
||||
record.addedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && visible(node)) {
|
||||
state.visibleMounts += 1
|
||||
state.visibleRows.add(node)
|
||||
}
|
||||
if (!(node instanceof Element)) return
|
||||
const added = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
|
||||
added.forEach((element) => {
|
||||
if (visible(element)) state.visibleSubtreeMounts.push(describe(element))
|
||||
})
|
||||
})
|
||||
record.removedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && state.visibleRows.delete(node))
|
||||
state.visibleUnmounts += 1
|
||||
if (!(node instanceof Element)) return
|
||||
const removed = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
|
||||
removed.forEach((element) => {
|
||||
const key = describe(element)
|
||||
if (state.visibleSubtrees.get(key) === element) state.visibleSubtreeUnmounts.push(key)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
const mutationObserver = profileVisual ? new MutationObserver(recordMutations) : undefined
|
||||
mutationObserver?.observe(root, { childList: true, subtree: true })
|
||||
const currentPart = () => root.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
|
||||
const observeProgress = (at: number) => {
|
||||
if (!state.running) return
|
||||
const content = currentPart()?.textContent ?? ""
|
||||
const index = content.includes("benchmark-complete")
|
||||
? finalIndex
|
||||
: Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
|
||||
if (index >= 0 && index !== state.applied.at(-1)?.index) state.applied.push({ at, index })
|
||||
}
|
||||
const progressObserver = new MutationObserver(() => observeProgress(performance.now()))
|
||||
progressObserver.observe(root, { characterData: true, childList: true, subtree: true })
|
||||
state.cleanup = () => {
|
||||
recordLongTasks(longTaskObserver.takeRecords())
|
||||
recordLayoutShifts(layoutShiftObserver?.takeRecords() ?? [])
|
||||
recordMutations(mutationObserver?.takeRecords() ?? [])
|
||||
if (progressObserver.takeRecords().length) observeProgress(performance.now())
|
||||
longTaskObserver.disconnect()
|
||||
layoutShiftObserver?.disconnect()
|
||||
mutationObserver?.disconnect()
|
||||
progressObserver.disconnect()
|
||||
if (!profileVisual) return
|
||||
Element.prototype.scrollTo = scrollTo
|
||||
Object.defineProperty(Element.prototype, "scrollTop", scrollTop)
|
||||
}
|
||||
|
||||
const sample = (now: number) => {
|
||||
if (!state.running) return
|
||||
state.frameAt.push(now)
|
||||
observeProgress(now)
|
||||
if (minimal) {
|
||||
state.frames.push(now - state.previous)
|
||||
state.previous = now
|
||||
requestAnimationFrame(sample)
|
||||
return
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!state.running) return
|
||||
state.scroll.frame += 1
|
||||
const duration = now - state.previous
|
||||
state.frames.push(duration)
|
||||
state.previous = now
|
||||
const virtualRoot = root.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||
const header = root.querySelector<HTMLElement>("[data-session-title]")
|
||||
state.geometry.push({
|
||||
scrollTop: root.scrollTop,
|
||||
scrollHeight: root.scrollHeight,
|
||||
clientHeight: root.clientHeight,
|
||||
distance: root.scrollHeight - root.clientHeight - root.scrollTop,
|
||||
virtualHeight: virtualRoot?.getBoundingClientRect().height ?? 0,
|
||||
headerHeight: header?.getBoundingClientRect().height ?? 0,
|
||||
})
|
||||
const viewport = root.getBoundingClientRect()
|
||||
if (profileVisual) {
|
||||
const visibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
||||
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
|
||||
.filter((item) => item.rect.bottom > viewport.top && item.rect.top < viewport.bottom)
|
||||
.sort((a, b) => a.rect.top - b.rect.top)
|
||||
state.visibleRows = new Set(visibleRows.map((item) => item.element))
|
||||
const rows = visibleRows.map((item) => item.rect)
|
||||
rows.slice(1).forEach((rect, index) => {
|
||||
const previous = rows[index]!
|
||||
state.maxOverlap = Math.max(state.maxOverlap, previous.bottom - rect.top)
|
||||
state.maxGap = Math.max(state.maxGap, rect.top - previous.bottom)
|
||||
})
|
||||
const partTop = part.getBoundingClientRect().top
|
||||
state.maxPartTopMovement = Math.max(state.maxPartTopMovement, Math.abs(partTop - state.previousPartTop))
|
||||
state.previousPartTop = partTop
|
||||
}
|
||||
const visibleRow = [...root.querySelectorAll<HTMLElement>("[data-timeline-row]")].some((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > viewport.top && rect.top < viewport.bottom
|
||||
})
|
||||
if (!visibleRow) state.blanks += 1
|
||||
if (profileVisual) {
|
||||
const subtrees = new Map<string, { element: Element; rendered: boolean }>()
|
||||
const visibleSubtrees = new Map<string, Element>()
|
||||
root.querySelectorAll(critical).forEach((element) => {
|
||||
const key = describe(element)
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
const rendered =
|
||||
element.isConnected &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
Number(style.opacity) > 0
|
||||
subtrees.set(key, { element, rendered })
|
||||
if (rendered && rect.bottom > viewport.top && rect.top < viewport.bottom) {
|
||||
const previous = state.visibleSubtrees.get(key)
|
||||
if (previous && previous !== element && key.startsWith(`${textPartID}:`))
|
||||
state.visibleSubtreeReplacements += 1
|
||||
visibleSubtrees.set(key, element)
|
||||
}
|
||||
})
|
||||
state.visibleSubtrees.forEach((element, key) => {
|
||||
const current = subtrees.get(key)
|
||||
if (key.startsWith(`${textPartID}:`) && !current?.rendered) {
|
||||
const markdown = part.querySelector<HTMLElement>('[data-component="markdown"]')
|
||||
state.visibleSubtreeDropouts.push(
|
||||
`${key}:projection=${markdown?.dataset.markdownProjectionLength}/${markdown?.dataset.markdownProjectionBlocks}:result=${markdown?.dataset.markdownResultLength}/${markdown?.dataset.markdownResultBlocks}:applied=${markdown?.dataset.markdownAppliedBlocks}:dom=${markdown?.children.length}`,
|
||||
)
|
||||
}
|
||||
if (element.matches('[data-component="file"]')) {
|
||||
const hadLines = element.hasAttribute("data-profiler-had-lines")
|
||||
const hasLines = element.shadowRoot?.querySelector("[data-line]") != null
|
||||
if (hasLines) element.setAttribute("data-profiler-had-lines", "")
|
||||
if (hadLines && !hasLines) state.visibleSubtreeDropouts.push(`${key}:shadow-lines`)
|
||||
}
|
||||
})
|
||||
state.visibleSubtrees = visibleSubtrees
|
||||
}
|
||||
if (profileVisual && duration > 33.34) {
|
||||
const livePart = currentPart()
|
||||
const content = livePart?.textContent ?? ""
|
||||
const complete = content.includes("benchmark-complete")
|
||||
const index = complete
|
||||
? finalIndex
|
||||
: Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
|
||||
state.slowFrames.push({
|
||||
duration,
|
||||
index,
|
||||
phase: complete
|
||||
? "complete"
|
||||
: index >= 0 && index % fragmentCount === 0
|
||||
? "boundary"
|
||||
: index >= 0
|
||||
? "stream"
|
||||
: "unknown",
|
||||
tokenSpans: livePart?.querySelectorAll(".shiki span").length ?? 0,
|
||||
blocks: livePart?.querySelectorAll("[data-markdown-block]").length ?? 0,
|
||||
codeBlocks: livePart?.querySelectorAll('[data-component="markdown-code"]').length ?? 0,
|
||||
height: livePart?.getBoundingClientRect().height ?? 0,
|
||||
distance: root.scrollHeight - root.clientHeight - root.scrollTop,
|
||||
})
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
}
|
||||
state.start = () => {
|
||||
state.started = performance.now()
|
||||
state.previous = state.started
|
||||
state.running = true
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
},
|
||||
{ ...options, markerPattern: STREAM_MARKER_PATTERN, fragmentCount: STREAM_FRAGMENT_COUNT },
|
||||
)
|
||||
}
|
||||
|
||||
export function startTimelineStreamProbe(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
|
||||
if (!state) throw new Error("missing streaming benchmark state")
|
||||
state.start()
|
||||
})
|
||||
}
|
||||
|
||||
type LayoutShiftEntry = PerformanceEntry & { value: number; hadRecentInput?: boolean }
|
||||
|
||||
export function layoutShiftValue(
|
||||
entry: Pick<LayoutShiftEntry, "startTime" | "value" | "hadRecentInput">,
|
||||
start: number,
|
||||
) {
|
||||
if (entry.startTime < start || entry.hadRecentInput) return
|
||||
return entry.value
|
||||
}
|
||||
|
||||
export function removeVisibleRow<T>(visible: Set<T>, row: T) {
|
||||
return visible.delete(row)
|
||||
}
|
||||
|
||||
export function streamProgress(content: string) {
|
||||
const index = Number(content.match(new RegExp(STREAM_MARKER_PATTERN, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
|
||||
return {
|
||||
index,
|
||||
phase: content.includes("benchmark-complete")
|
||||
? ("complete" as const)
|
||||
: index >= 0 && index % STREAM_FRAGMENT_COUNT === 0
|
||||
? ("boundary" as const)
|
||||
: index >= 0
|
||||
? ("stream" as const)
|
||||
: ("unknown" as const),
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectTimelineStreamMetrics(
|
||||
page: Page,
|
||||
options: { textPartID: string; finalIndex: number; navigations: string[] },
|
||||
) {
|
||||
return page.evaluate(({ textPartID, finalIndex, navigations }) => {
|
||||
const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
|
||||
if (!state) throw new Error(`missing streaming benchmark state after navigation: ${JSON.stringify(navigations)}`)
|
||||
state.ended = performance.now()
|
||||
state.cleanup()
|
||||
state.running = false
|
||||
const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
|
||||
const row = part?.closest<HTMLElement>("[data-timeline-row]")
|
||||
const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
|
||||
const sorted = state.frames.slice().sort((a, b) => a - b)
|
||||
const duration = state.frames.reduce((sum, value) => sum + value, 0)
|
||||
const longestSlowStreak = state.frames.reduce(
|
||||
(result, value) => {
|
||||
const current = value > 33.34 ? result.current + 1 : 0
|
||||
return { current, longest: Math.max(result.longest, current) }
|
||||
},
|
||||
{ current: 0, longest: 0 },
|
||||
).longest
|
||||
const busyStart = state.applied.at(0)?.at
|
||||
const completion = state.applied.find((value) => value.index === finalIndex)
|
||||
const busyEnd = completion?.at
|
||||
const busyFrames =
|
||||
busyStart === undefined || busyEnd === undefined
|
||||
? []
|
||||
: state.frames.filter((_, index) => state.frameAt[index]! >= busyStart && state.frameAt[index]! <= busyEnd)
|
||||
const busySorted = busyFrames.slice().sort((a, b) => a - b)
|
||||
const busyDuration = busyFrames.reduce((sum, value) => sum + value, 0)
|
||||
const completionObservedMs = (completion?.at ?? NaN) - state.started
|
||||
const visual = state.profileVisual
|
||||
? {
|
||||
layoutShiftValueSum: state.layoutShifts.reduce((sum, value) => sum + value, 0),
|
||||
maxLayoutShiftValue: Math.max(0, ...state.layoutShifts),
|
||||
visibleMounts: state.visibleMounts,
|
||||
visibleUnmounts: state.visibleUnmounts,
|
||||
visibleSubtreeMounts: state.visibleSubtreeMounts,
|
||||
visibleSubtreeUnmounts: [...new Set(state.visibleSubtreeUnmounts)],
|
||||
visibleSubtreeReplacements: state.visibleSubtreeReplacements,
|
||||
visibleSubtreeDropouts: [...new Set(state.visibleSubtreeDropouts)],
|
||||
maxOverlapPx: state.maxOverlap,
|
||||
maxGapPx: state.maxGap,
|
||||
maxPartTopMovementPx: state.maxPartTopMovement,
|
||||
slowestRafGaps: state.slowFrames
|
||||
.sort((a, b) => b.duration - a.duration)
|
||||
.slice(0, 20)
|
||||
.map((frame) => ({
|
||||
durationMs: frame.duration,
|
||||
index: frame.index,
|
||||
phase: frame.phase,
|
||||
tokenSpans: frame.tokenSpans,
|
||||
blocks: frame.blocks,
|
||||
codeBlocks: frame.codeBlocks,
|
||||
heightPx: frame.height,
|
||||
distancePx: frame.distance,
|
||||
})),
|
||||
slowRafGapPhases: Object.fromEntries(
|
||||
["stream", "boundary", "complete", "unknown"].map((phase) => {
|
||||
const frames = state.slowFrames.filter((frame) => frame.phase === phase)
|
||||
return [
|
||||
phase,
|
||||
{
|
||||
count: frames.length,
|
||||
totalMs: frames.reduce((sum, frame) => sum + frame.duration, 0),
|
||||
maxMs: Math.max(0, ...frames.map((frame) => frame.duration)),
|
||||
},
|
||||
]
|
||||
}),
|
||||
),
|
||||
scroll: state.scroll,
|
||||
}
|
||||
: null
|
||||
const geometry = state.minimal
|
||||
? null
|
||||
: {
|
||||
maxDistancePx: Math.max(0, ...state.geometry.map((sample) => sample.distance)),
|
||||
finalDistancePx: state.geometry.at(-1)?.distance ?? 0,
|
||||
final: state.geometry.at(-1),
|
||||
distanceTransitionsPx: state.geometry
|
||||
.map((sample) => Math.round(sample.distance))
|
||||
.filter((value, index, values) => index === 0 || value !== values[index - 1]),
|
||||
bottomDriftTransitions: state.geometry.slice(1).filter((value, index) => {
|
||||
const previous = state.geometry[index]?.distance ?? 0
|
||||
return previous <= 1 && value.distance > 1
|
||||
}).length,
|
||||
blankSamples: state.blanks,
|
||||
}
|
||||
return {
|
||||
capabilities: { visual: state.profileVisual, geometry: !state.minimal },
|
||||
completionObservedMs,
|
||||
deltasPerSecond: Number.isFinite(completionObservedMs) ? finalIndex / (completionObservedMs / 1_000) : null,
|
||||
rafGapSamples: state.frames.length,
|
||||
rafCallbackRate: duration ? (state.frames.length * 1000) / duration : 0,
|
||||
observedProgressWindowRafCallbackRate: busyDuration ? (busyFrames.length * 1000) / busyDuration : null,
|
||||
observedProgressWindowRafGapP95Ms: busySorted[Math.floor(busySorted.length * 0.95)] ?? null,
|
||||
observedProgressWindowRafGaps: busyFrames.length,
|
||||
maxObservedProgressIndex: Math.max(-1, ...state.applied.map((value) => value.index)),
|
||||
observedProgressTransitions: state.applied.length,
|
||||
rafGapP50Ms: sorted[Math.floor(sorted.length * 0.5)] ?? 0,
|
||||
rafGapP95Ms: sorted[Math.floor(sorted.length * 0.95)] ?? 0,
|
||||
rafGapP99Ms: sorted[Math.floor(sorted.length * 0.99)] ?? 0,
|
||||
maxRafGapMs: sorted.at(-1) ?? 0,
|
||||
rafGapsOver33Ms: state.frames.filter((value) => value > 33.34).length,
|
||||
rafGapsOver50Ms: state.frames.filter((value) => value > 50).length,
|
||||
missedFrameBudgetEquivalents: state.frames.reduce(
|
||||
(sum, value) => sum + Math.max(0, Math.round(value / 16.67) - 1),
|
||||
0,
|
||||
),
|
||||
longestRafGapOver33MsStreak: longestSlowStreak,
|
||||
longTaskCount: state.longTasks.length,
|
||||
longTaskTimeMs: state.longTasks.reduce((sum, value) => sum + value, 0),
|
||||
visual,
|
||||
geometry,
|
||||
rowReplaced: row !== state.row,
|
||||
markdownReplaced: markdown !== state.markdown,
|
||||
domTextCharacters: part?.textContent?.length ?? 0,
|
||||
}
|
||||
}, options)
|
||||
}
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
const words = [
|
||||
"alpha",
|
||||
"bravo",
|
||||
"charlie",
|
||||
"delta",
|
||||
"echo",
|
||||
"foxtrot",
|
||||
"golf",
|
||||
"hotel",
|
||||
"india",
|
||||
"juliet",
|
||||
"kilo",
|
||||
"lima",
|
||||
"metro",
|
||||
"nova",
|
||||
"orbit",
|
||||
"pixel",
|
||||
"quartz",
|
||||
"river",
|
||||
"signal",
|
||||
"vector",
|
||||
]
|
||||
|
||||
const sourceID = "ses_smoke_source"
|
||||
const targetID = "ses_smoke_target"
|
||||
const directory = "C:/OpenCode/SmokeProject"
|
||||
const projectID = "proj_smoke_timeline"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
|
||||
type Message = { info: MessageInfo; parts: MessagePart[] }
|
||||
|
||||
function lorem(seed: number, length: number) {
|
||||
let out = ""
|
||||
let i = seed
|
||||
while (out.length < length) {
|
||||
const word = words[i % words.length]
|
||||
out += (out ? " " : "") + word
|
||||
if (i % 17 === 0) out += ".\n\n"
|
||||
i += 7
|
||||
}
|
||||
return out.slice(0, length)
|
||||
}
|
||||
|
||||
function id(prefix: string, value: number) {
|
||||
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
||||
}
|
||||
|
||||
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
|
||||
const messageID = id("msg_user", index)
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
summary: { diffs },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: id("prt_user_text", index),
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: lorem(index, textLength),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
|
||||
const messageID = id("msg_assistant", index)
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
parentID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
finish: "stop",
|
||||
},
|
||||
parts: parts.map((part) => ({
|
||||
...part,
|
||||
sessionID,
|
||||
messageID,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function textPart(index: number, partIndex: number, length: number): MessagePart {
|
||||
const prose = lorem(index * 13 + partIndex, length)
|
||||
const text =
|
||||
index % 12 === 0
|
||||
? `${prose}\n\n\`\`\`ts\n${code(index, 80)}\n\`\`\``
|
||||
: index % 5 === 0
|
||||
? `${prose}\n\n\`\`\`ts\nexport const value = "${lorem(index, 220)}"\n\`\`\``
|
||||
: index % 7 === 0
|
||||
? `${prose}\n\nThe wrapped inline value is \`${lorem(index, 180)}\`.`
|
||||
: prose
|
||||
return { id: id(`prt_text_${partIndex}`, index), type: "text", text }
|
||||
}
|
||||
|
||||
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
|
||||
return {
|
||||
id: id(`prt_reasoning_${partIndex}`, index),
|
||||
type: "reasoning",
|
||||
text: lorem(index * 19 + partIndex, length),
|
||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
|
||||
}
|
||||
}
|
||||
|
||||
function toolPart(
|
||||
index: number,
|
||||
partIndex: number,
|
||||
tool: string,
|
||||
input: Record<string, unknown>,
|
||||
outputLength = 160,
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
tool === "apply_patch"
|
||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||
: tool === "edit" || tool === "write"
|
||||
? {
|
||||
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
|
||||
diff: patch(index, outputLength),
|
||||
preview: patch(index + 1, 420),
|
||||
}
|
||||
: tool === "question"
|
||||
? { answers: [["Proceed"], ["Keep sample output"]] }
|
||||
: {}
|
||||
return {
|
||||
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
||||
type: "tool",
|
||||
callID: id("call", index * 10 + partIndex),
|
||||
tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input,
|
||||
output: lorem(index * 23 + partIndex, outputLength),
|
||||
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
|
||||
metadata,
|
||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function patchFile(seed: number, type: "add" | "update" | "delete") {
|
||||
return {
|
||||
filePath: `src/generated/patch-${seed}.ts`,
|
||||
relativePath: `src/generated/patch-${seed}.ts`,
|
||||
type,
|
||||
additions: (seed % 7) + 1,
|
||||
deletions: type === "add" ? 0 : seed % 4,
|
||||
patch: patch(seed, 520),
|
||||
before: type === "add" ? undefined : code(seed, 18),
|
||||
after: type === "delete" ? undefined : code(seed + 1, 24),
|
||||
}
|
||||
}
|
||||
|
||||
function fileDiff(file: string, seed: number) {
|
||||
const lines = seed % 12 === 0 ? 300 : seed % 8 === 0 ? 2 : 38
|
||||
const before = code(seed, lines, seed % 10 === 0 ? 280 : 32)
|
||||
const after =
|
||||
lines === 2
|
||||
? before.replace("value1", "updatedValue1")
|
||||
: lines === 300
|
||||
? code(seed + 1, lines, seed % 10 === 0 ? 280 : 32)
|
||||
: before.replace("value4", "updatedValue4").replace("value20", "updatedValue20")
|
||||
return {
|
||||
file,
|
||||
additions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
|
||||
deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
|
||||
before,
|
||||
after,
|
||||
}
|
||||
}
|
||||
|
||||
function patch(seed: number, length: number) {
|
||||
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
|
||||
}
|
||||
|
||||
function code(seed: number, lines: number, width = 32) {
|
||||
return Array.from(
|
||||
{ length: lines },
|
||||
(_, index) => `export const value${index} = "${lorem(seed + index, width)}"`,
|
||||
).join("\n")
|
||||
}
|
||||
|
||||
function turn(index: number): Message[] {
|
||||
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
||||
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
||||
const parts = [
|
||||
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
|
||||
...(index % 3 === 0
|
||||
? [
|
||||
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
|
||||
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
|
||||
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
|
||||
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
|
||||
]
|
||||
: []),
|
||||
textPart(index, 2, 160 + (index % 6) * 90),
|
||||
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
|
||||
...(index % 6 === 0
|
||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0
|
||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||
: []),
|
||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
||||
...(index % 13 === 0
|
||||
? [
|
||||
toolPart(
|
||||
index,
|
||||
11,
|
||||
"question",
|
||||
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
|
||||
120,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(index % 17 === 0
|
||||
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
||||
: []),
|
||||
]
|
||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
||||
}
|
||||
|
||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
userMessage(sourceID, index + 1000, 120),
|
||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||
]).flat()
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
}
|
||||
|
||||
function orderedParts(message: Message) {
|
||||
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
export const fixture = {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "smoke-project",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sourceID,
|
||||
slug: "source",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Uncommitted changes inquiry",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
{
|
||||
id: targetID,
|
||||
slug: "target",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Example Game: sample jump movement & sample physics analysis",
|
||||
version: "dev",
|
||||
time: { created: 1700000001000, updated: 1700000001000 },
|
||||
},
|
||||
],
|
||||
sourceID,
|
||||
targetID,
|
||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
|
||||
expected: {
|
||||
sourceTitle: "Uncommitted changes inquiry",
|
||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||
sourceMessageIDs: sourceMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
targetMessageIDs: targetMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
targetPartIDs: targetMessages.flatMap((message) =>
|
||||
orderedParts(message)
|
||||
.filter(renderable)
|
||||
.map((part) => part.id),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
||||
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
||||
const end = before
|
||||
? Math.max(
|
||||
0,
|
||||
messages.findIndex((message) => message.info.id === before),
|
||||
)
|
||||
: messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import type { Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
|
||||
export async function installTimelineSettings(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
showSessionProgressBar: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function mockStressTimeline(page: Page) {
|
||||
return mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function installStressSessionTabs(page: Page) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript(
|
||||
({ directory, sourceID, targetID, dirBase64, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
server,
|
||||
dirBase64,
|
||||
sessionId,
|
||||
})),
|
||||
),
|
||||
)
|
||||
},
|
||||
{
|
||||
directory: fixture.directory,
|
||||
sourceID: fixture.sourceID,
|
||||
targetID: fixture.targetID,
|
||||
dirBase64: base64Encode(fixture.directory),
|
||||
server,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function stressSessionHref(sessionID: string) {
|
||||
return `/${base64Encode(fixture.directory)}/session/${sessionID}`
|
||||
}
|
||||
15
packages/app/e2e/performance/unit/chrome-trace-write.test.ts
Normal file
15
packages/app/e2e/performance/unit/chrome-trace-write.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
import { prepareChromeTrace } from "../chrome-trace"
|
||||
|
||||
test("creates the configured trace directory", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-trace-"))
|
||||
try {
|
||||
const file = await prepareChromeTrace(path.join(root, "nested", "traces"), "session/tab", false, "test")
|
||||
expect(file).toEndWith("-session-tab-458ed9e3-test.json")
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { compressCachedRepaintTrace, layoutShiftSample } from "../timeline/session-tab-repaint-probe"
|
||||
|
||||
test("compresses repeated repaint states without losing frame samples", () => {
|
||||
const state = {
|
||||
root: 1,
|
||||
scrollTop: 10,
|
||||
scrollHeight: 20,
|
||||
bottomErrorPx: 0,
|
||||
last: true,
|
||||
rows: [{ key: "row", node: 2, top: 0, bottom: 10 }],
|
||||
mounted: 1,
|
||||
center: "content",
|
||||
}
|
||||
const trace = {
|
||||
timeOriginEpochMs: 1_000,
|
||||
startedAtPerformanceMs: 100,
|
||||
samples: [
|
||||
{ observedAtMs: 16, ...state, destination: ["target"], source: [] },
|
||||
{ observedAtMs: 32, ...state, destination: ["target"], source: [] },
|
||||
{ observedAtMs: 48, ...state, scrollTop: 11, destination: ["target"], source: [] },
|
||||
],
|
||||
mutations: [{ observedAtMs: 20, changed: [{ type: "add", node: 2 }] }],
|
||||
shifts: [{ occurredAtMs: 24, value: 0.1 }],
|
||||
windowMs: 1_000,
|
||||
running: false,
|
||||
stop() {},
|
||||
}
|
||||
const compressed = compressCachedRepaintTrace(trace)
|
||||
const samples = compressed.samples.flatMap((group) =>
|
||||
group.observedAtMs.map((observedAtMs) => ({ observedAtMs, ...group.state })),
|
||||
)
|
||||
|
||||
expect(samples).toEqual(trace.samples)
|
||||
expect(compressed.mutations).toEqual(trace.mutations)
|
||||
expect(compressed.shifts).toEqual(trace.shifts)
|
||||
})
|
||||
|
||||
test("records layout shifts at occurrence time within the probe window", () => {
|
||||
expect(layoutShiftSample({ startTime: 99, value: 0.1 }, 100)).toBeUndefined()
|
||||
expect(layoutShiftSample({ startTime: 124, value: 0.2 }, 100)).toEqual({ occurredAtMs: 24, value: 0.2 })
|
||||
})
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { classifySessionSwitch } from "../timeline/session-tab-switch-metrics"
|
||||
|
||||
test("counts source and blank samples before the destination is observed", () => {
|
||||
const result = classifySessionSwitch([
|
||||
{ observedAtMs: 16, destination: [], source: ["source"], hasVisibleRows: true, last: false },
|
||||
{ observedAtMs: 32, destination: [], source: [], hasVisibleRows: false, last: false },
|
||||
{ observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
||||
{ observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
||||
{ observedAtMs: 80, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
||||
])
|
||||
|
||||
expect(result.blankSamples).toBe(1)
|
||||
expect(result.sourceSamples).toBe(1)
|
||||
expect(result.unknownSamples).toBe(0)
|
||||
expect(result.firstDestinationObservedMs).toBe(48)
|
||||
expect(result.stableObservedMs).toBe(80)
|
||||
})
|
||||
|
||||
test("does not classify mixed source and destination content as correct", () => {
|
||||
const result = classifySessionSwitch([
|
||||
{
|
||||
observedAtMs: 16,
|
||||
destination: ["destination"],
|
||||
source: ["source"],
|
||||
hasVisibleRows: true,
|
||||
last: true,
|
||||
bottomErrorPx: 0,
|
||||
},
|
||||
{ observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
||||
{ observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
||||
{ observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
||||
])
|
||||
|
||||
expect(result.firstCorrectObservedMs).toBe(32)
|
||||
expect(result.stableObservedMs).toBe(64)
|
||||
})
|
||||
|
||||
test("reports missing correctness without throwing", () => {
|
||||
const result = classifySessionSwitch([
|
||||
{
|
||||
observedAtMs: 16,
|
||||
destination: ["destination"],
|
||||
source: ["source"],
|
||||
hasVisibleRows: true,
|
||||
last: true,
|
||||
bottomErrorPx: 0,
|
||||
},
|
||||
])
|
||||
|
||||
expect(result.firstDestinationObservedMs).toBe(16)
|
||||
expect(result.firstCorrectObservedMs).toBeNull()
|
||||
expect(result.stableObservedMs).toBeNull()
|
||||
})
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { streamChunk } from "../timeline/session-timeline-benchmark.fixture"
|
||||
import { streamProgress } from "../timeline/session-timeline-stream-probe"
|
||||
|
||||
test("classifies emitted stream markers using the fixture cycle", () => {
|
||||
expect(streamProgress("before stream-17 after stream-18")).toEqual({ index: 18, phase: "boundary" })
|
||||
expect(streamProgress("before stream-18 after stream-19")).toEqual({ index: 19, phase: "stream" })
|
||||
expect(streamProgress("benchmark-complete stream-36")).toEqual({ index: 36, phase: "complete" })
|
||||
expect(streamProgress("no marker")).toEqual({ index: -1, phase: "unknown" })
|
||||
})
|
||||
|
||||
test("emits progress markers at fixture boundaries", () => {
|
||||
expect(streamProgress(streamChunk(18, 160))).toEqual({ index: 18, phase: "boundary" })
|
||||
})
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { layoutShiftValue, removeVisibleRow } from "../timeline/session-timeline-stream-probe"
|
||||
|
||||
test("excludes layout shifts before the probe window and recent input", () => {
|
||||
expect(layoutShiftValue({ startTime: 9, value: 0.1 }, 10)).toBeUndefined()
|
||||
expect(layoutShiftValue({ startTime: 10, value: 0.2, hadRecentInput: true }, 10)).toBeUndefined()
|
||||
expect(layoutShiftValue({ startTime: 11, value: 0.3 }, 10)).toBe(0.3)
|
||||
})
|
||||
|
||||
test("classifies removed rows from their last painted visibility", () => {
|
||||
const row = {}
|
||||
const visible = new Set([row])
|
||||
|
||||
expect(removeVisibleRow(visible, row)).toBe(true)
|
||||
expect(removeVisibleRow(visible, row)).toBe(false)
|
||||
})
|
||||
|
|
@ -21,6 +21,7 @@ const words = [
|
|||
"vector",
|
||||
]
|
||||
|
||||
const serverKey = "http://127.0.0.1:4096"
|
||||
const sourceID = "ses_smoke_source"
|
||||
const targetID = "ses_smoke_target"
|
||||
const directory = "C:/OpenCode/SmokeProject"
|
||||
|
|
@ -139,7 +140,7 @@ function toolPart(
|
|||
status: "completed",
|
||||
input,
|
||||
output: lorem(index * 23 + partIndex, outputLength),
|
||||
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
|
||||
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
|
||||
metadata,
|
||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
||||
},
|
||||
|
|
@ -200,9 +201,7 @@ function turn(index: number): Message[] {
|
|||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0
|
||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
||||
...(index % 13 === 0
|
||||
|
|
@ -242,6 +241,7 @@ function orderedParts(message: Message) {
|
|||
|
||||
export const fixture = {
|
||||
directory,
|
||||
serverKey,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
|
|
@ -295,6 +295,7 @@ export const fixture = {
|
|||
.filter(renderable)
|
||||
.map((part) => part.id),
|
||||
),
|
||||
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -327,6 +327,18 @@ test.describe("smoke: session timeline", () => {
|
|||
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
||||
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
|
||||
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
|
||||
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
|
||||
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
|
||||
await expect(shellSubtitle).toHaveCount(0)
|
||||
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
|
||||
await shellTrigger.click()
|
||||
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(shellSubtitle).toHaveText("bun typecheck")
|
||||
await shellTrigger.click()
|
||||
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(shellSubtitle).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -706,7 +718,8 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
|
|||
}
|
||||
|
||||
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
|
||||
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}`
|
||||
console.log(process.env)
|
||||
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
if (url.port !== targetPort) return route.fallback()
|
||||
const appPort = new URL(
|
||||
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
|
||||
).port
|
||||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
||||
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||
|
|
@ -72,7 +75,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
|
||||
}
|
||||
|
||||
return json(route, {})
|
||||
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
|
||||
return route.fallback()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
@ -24,7 +24,8 @@
|
|||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report"
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const reuse = !process.env.CI
|
|||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
|
||||
outputDir: "./e2e/test-results",
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo"
|
|||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
|
|
@ -30,7 +30,7 @@ import { Dynamic } from "solid-js/web"
|
|||
import { CommandProvider } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { GlobalProvider } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
|
|
@ -47,11 +47,14 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
|||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
|
||||
|
||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||
const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome })))
|
||||
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
|
||||
|
|
@ -64,6 +67,10 @@ const SessionRoute = Object.assign(
|
|||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
|
||||
if (params.id && settings.general.newLayoutDesigns()) {
|
||||
return <Navigate href={sessionHref(server.key, params.id)} />
|
||||
}
|
||||
|
||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
||||
// is replaced by a draft at /new-session?draftId=…
|
||||
createEffect(() => {
|
||||
|
|
@ -82,29 +89,55 @@ const SessionRoute = Object.assign(
|
|||
{ preload: Session.preload },
|
||||
)
|
||||
|
||||
const TargetSessionRoute = Object.assign(
|
||||
() => {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
return (
|
||||
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
{ preload: Session.preload },
|
||||
)
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||
// Notification/Models + the visual Layout) for that server.
|
||||
function SelectedServerLayout(props: ParentProps) {
|
||||
function SelectedServerProviders(props: ParentProps) {
|
||||
return (
|
||||
<ServerKey>
|
||||
<ServerSDKProvider>
|
||||
<ServerSyncProvider>
|
||||
<ServerScopedShell>{props.children}</ServerScopedShell>
|
||||
</ServerSyncProvider>
|
||||
<ServerSyncProvider>{props.children}</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</ServerKey>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyServerLayout(props: ParentProps) {
|
||||
return (
|
||||
<SelectedServerProviders>
|
||||
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
|
||||
</SelectedServerProviders>
|
||||
)
|
||||
}
|
||||
|
||||
// Wraps /new-session. It resolves the draft's target server and provides the
|
||||
// server-scoped shell for that server — without ServerKey, so the page never depends
|
||||
// on the globally "selected" server.
|
||||
function DraftServerLayout(props: ParentProps) {
|
||||
function TargetServerLayout(props: ParentProps) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const params = useParams<{ serverKey?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const conn = createMemo(() => {
|
||||
if (params.serverKey) {
|
||||
const key = requireServerKey(params.serverKey)
|
||||
return server.list.find((item) => ServerConnection.key(item) === key)
|
||||
}
|
||||
const id = search.draftId
|
||||
if (!id) return undefined
|
||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
|
||||
|
|
@ -115,48 +148,98 @@ function DraftServerLayout(props: ParentProps) {
|
|||
return (
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ServerScopedShell>{props.children}</ServerScopedShell>
|
||||
<TargetDirectoryLayout>{props.children}</TargetDirectoryLayout>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetDirectoryLayout(props: ParentProps) {
|
||||
const params = useParams<{ serverKey?: string; id?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverKey = createMemo(() => {
|
||||
if (params.serverKey) return requireServerKey(params.serverKey)
|
||||
if (!search.draftId) return undefined
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.server
|
||||
})
|
||||
|
||||
const resolved = useQuery(() => ({
|
||||
queryKey: [serverSDK().scope, "session-route", params.id] as const,
|
||||
enabled: !!params.serverKey && !!params.id,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () => {
|
||||
const session = (await serverSDK().client.session.get({ sessionID: params.id! })).data!
|
||||
const root = await rootSession(session, (sessionID) =>
|
||||
serverSDK()
|
||||
.client.session.get({ sessionID })
|
||||
.then((result) => result.data!),
|
||||
)
|
||||
return { session, rootID: root.id }
|
||||
},
|
||||
}))
|
||||
const resolvedDirectory = createMemo(() => {
|
||||
if (params.serverKey) return resolved.data?.session.directory
|
||||
if (!search.draftId) return undefined
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory
|
||||
})
|
||||
const directory = createMemo<string | undefined>((prev) => prev ?? resolvedDirectory())
|
||||
const home = () => !params.serverKey && !search.draftId
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
const current = resolved.data
|
||||
const key = serverKey()
|
||||
if (!current || !key) return
|
||||
tabs.addSessionTab({
|
||||
server: key,
|
||||
sessionId: current.rootID,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<NewServerScopedShell directory={() => (home() ? undefined : directory())} sessionID={() => params.id}>
|
||||
<Show when={!home()} fallback={props.children}>
|
||||
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
|
||||
<Show when={directory()}>
|
||||
<Show
|
||||
when={!params.serverKey || settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={legacySessionHref(directory()!, params.id!)} />}
|
||||
>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<Show when={!params.serverKey || (resolved.data && !resolved.isPlaceholderData)}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</NewServerScopedShell>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
|
||||
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
|
||||
<ResolvedDraftRoute />
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedDraftRoute(props: { draftID: string }) {
|
||||
const tabs = useTabs()
|
||||
const draft = createMemo(() =>
|
||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
|
||||
)
|
||||
|
||||
// Key on the directory so retargeting the draft's project re-instantiates the
|
||||
// directory-scoped providers while keeping the same draft id. The draft's target
|
||||
// server is provided by DraftServerLayout, so changing only the server updates the
|
||||
// SDK/sync hooks without remounting the composer.
|
||||
const directory = () => draft()?.directory
|
||||
|
||||
function ResolvedDraftRoute() {
|
||||
return (
|
||||
<Show when={directory()} keyed>
|
||||
{(dir) => (
|
||||
<SDKProvider directory={dir}>
|
||||
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
)}
|
||||
</Show>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -210,32 +293,51 @@ function BodyDesignClass() {
|
|||
// shell (router root) so they stay mounted regardless of the active server/route.
|
||||
function SharedProviders(props: ParentProps) {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<>
|
||||
<BodyDesignClass />
|
||||
<CommandProvider>
|
||||
<HighlightsProvider>{props.children}</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</SettingsProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
|
||||
// each per-route server layout so they resolve to that route's server (selected vs
|
||||
// draft). The Layout remounts when crossing between those groups.
|
||||
function ServerScopedShell(props: ParentProps) {
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
sessionID?: () => string | undefined
|
||||
}>
|
||||
|
||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider>
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<ModelsProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</ModelsProvider>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
|
||||
<LegacyLayout>{props.children}</LegacyLayout>
|
||||
</ServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function NewServerScopedShell(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
|
||||
<NewLayout>{props.children}</NewLayout>
|
||||
</ServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionProviders(props: ParentProps) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
|
|
@ -439,28 +541,61 @@ export function AppInterface(props: {
|
|||
servers={props.servers}
|
||||
>
|
||||
<GlobalProvider>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<ServerShell>{routerProps.children}</ServerShell>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
<Route component={SelectedServerLayout}>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route component={DraftServerLayout}>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</ConnectionGate>
|
||||
<SettingsProvider>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<ServerShell>{routerProps.children}</ServerShell>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
<Routes />
|
||||
</Dynamic>
|
||||
</Show>
|
||||
</ConnectionGate>
|
||||
</SettingsProvider>
|
||||
</GlobalProvider>
|
||||
</ServerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function Routes() {
|
||||
const settings = useSettings()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Route component={LegacyServerLayout}>
|
||||
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route component={TargetServerLayout}>
|
||||
<Show when={settings.general.newLayoutDesigns()}>
|
||||
{
|
||||
<>
|
||||
<Route path="/" component={NewHome} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route
|
||||
path="/session/:id"
|
||||
component={() => {
|
||||
const server = useServer()
|
||||
const { id } = useParams()
|
||||
|
||||
return <Navigate href={`/server/${server.key}/session/${id}`} />
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
</>
|
||||
}
|
||||
</Show>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
</Route>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -388,12 +388,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
local.session.promote(sessionDirectory, session.id)
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID)
|
||||
tabs.promoteDraft(draftID, {
|
||||
server: server.key,
|
||||
dirBase64: base64Encode(sessionDirectory),
|
||||
sessionId: session.id,
|
||||
})
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import { useLanguage } from "@/context/language"
|
|||
import { useSettings } from "@/context/settings"
|
||||
import { WindowsAppMenu } from "./windows-app-menu"
|
||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||
|
|
@ -38,10 +37,11 @@ import { makeEventListener } from "@solid-primitives/event-listener"
|
|||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { tabHref, useTabs, type Tab } from "@/context/tabs"
|
||||
import { tabHref, useTabs } from "@/context/tabs"
|
||||
import "./titlebar.css"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { Session } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
|
|
@ -252,7 +252,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
<Switch>
|
||||
<Match when={useV2Titlebar()}>
|
||||
{(_) => {
|
||||
const serverSync = useServerSync()
|
||||
const serverSdk = useServerSDK()
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
|
||||
|
|
@ -268,6 +268,17 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
const tabs = useTabs()
|
||||
const tabsStore = tabs.store
|
||||
const tabsStoreActions = tabs
|
||||
const [session] = createResource(
|
||||
() => {
|
||||
const route = layout.route()
|
||||
return route.type === "session" ? route : undefined
|
||||
},
|
||||
(route) =>
|
||||
serverSdk()
|
||||
.client.session.get({ sessionID: route.sessionId })
|
||||
.then((x) => x.data)
|
||||
.catch(() => {}),
|
||||
)
|
||||
|
||||
const matchRoute = (route: LayoutRoute) => {
|
||||
if (route.type === "home") return
|
||||
|
|
@ -280,10 +291,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
|
||||
)
|
||||
if (main) return main
|
||||
const sync = serverSync().createDirSyncContext(route.dir)
|
||||
const session = sync.session.get(route.sessionId)
|
||||
if (session?.parentID) {
|
||||
const parentID = session.parentID
|
||||
const s = session()
|
||||
if (s?.parentID) {
|
||||
const parentID = s.parentID
|
||||
const parent = tabsStore.find(
|
||||
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
|
||||
)
|
||||
|
|
@ -304,15 +314,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
}
|
||||
|
||||
if (route.type === "session") {
|
||||
const sync = serverSync().createDirSyncContext(route.dir)
|
||||
const session = sync.session.get(route.sessionId)
|
||||
if (!session) return
|
||||
const sessionId = session.parentID ?? session.id
|
||||
const next = {
|
||||
server: route.server ?? server.key,
|
||||
dirBase64: route.dirBase64,
|
||||
sessionId,
|
||||
}
|
||||
const s = session()
|
||||
if (!s) return
|
||||
const sessionId = s.parentID ?? s.id
|
||||
const next = { server: route.server ?? server.key, sessionId }
|
||||
tabsStoreActions.addSessionTab(next)
|
||||
}
|
||||
})
|
||||
|
|
@ -495,25 +500,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
)
|
||||
}
|
||||
|
||||
const [session] = createResource(
|
||||
() => tab.sessionId,
|
||||
(sessionID) =>
|
||||
serverSdk()
|
||||
.client.session.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{divider()}
|
||||
<TabNavItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
server={tab.server}
|
||||
directory={decode64(tab.dirBase64)!}
|
||||
sessionId={tab.sessionId}
|
||||
onNavigate={() => {
|
||||
tabs.select(tab)
|
||||
<Show when={session()}>
|
||||
{(session) => (
|
||||
<TabNavItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
server={tab.server}
|
||||
sessionId={tab.sessionId}
|
||||
session={session()}
|
||||
onNavigate={() => {
|
||||
tabs.select(tab)
|
||||
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={() => tabsStoreActions.removeTab(i())}
|
||||
active={currentTab() === tab}
|
||||
activeServer={tab.server === server.key}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
/>
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={() => tabsStoreActions.removeTab(i())}
|
||||
active={currentTab() === tab}
|
||||
activeServer={tab.server === server.key}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
|
|
@ -793,7 +811,6 @@ function TabNavItem(props: {
|
|||
ref?: HTMLDivElement
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
directory: string
|
||||
sessionId?: string
|
||||
hideClose?: boolean
|
||||
onClose: () => void
|
||||
|
|
@ -801,31 +818,19 @@ function TabNavItem(props: {
|
|||
active?: boolean
|
||||
activeServer: boolean
|
||||
forceTruncate?: boolean
|
||||
session: Session
|
||||
}) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||
if (conn) return global.createServerCtx(conn)
|
||||
})
|
||||
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
|
||||
|
||||
const [session] = createResource(
|
||||
() => {
|
||||
const ctx = dirSyncCtx()
|
||||
if (!ctx || !props.sessionId) return
|
||||
return [props.sessionId, ctx] as const
|
||||
},
|
||||
async ([sessionId, dirSyncCtx]) => {
|
||||
await dirSyncCtx.session.sync(sessionId).catch(() => {})
|
||||
return dirSyncCtx.session.get(sessionId)
|
||||
},
|
||||
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -837,7 +842,7 @@ function TabNavItem(props: {
|
|||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<Show when={session.latest}>
|
||||
<Show when={props.session}>
|
||||
{(session) => {
|
||||
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
|
||||
|
||||
|
|
@ -853,7 +858,7 @@ function TabNavItem(props: {
|
|||
<span data-slot="project-avatar-slot">
|
||||
<ProjectTabAvatar
|
||||
project={project()}
|
||||
directory={props.directory}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
|||
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createScopedCache } from "@/utils/scoped-cache"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
export type LineComment = {
|
||||
id: string
|
||||
|
|
@ -202,6 +204,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
|||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
const cache = createScopedCache(
|
||||
(key) => {
|
||||
|
|
@ -228,7 +231,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
|||
return cache.get(key).value
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(params.dir!, params.id))
|
||||
const session = createMemo(() => load(base64Encode(sdk().directory), params.id))
|
||||
|
||||
return {
|
||||
ready: () => session().ready(),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
|||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
|
|
@ -65,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||
const scope = createMemo(() => sdk().directory)
|
||||
const path = createPathHelpers(scope)
|
||||
const tabs = layout.tabs(() =>
|
||||
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)),
|
||||
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
|
||||
)
|
||||
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { createPathHelpers } from "./file/path"
|
|||
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
|
||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
|
||||
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
||||
|
||||
|
|
@ -79,7 +80,7 @@ export type LayoutRoute =
|
|||
| { type: "home" }
|
||||
| { type: "draft"; draftID: string; server?: ServerConnection.Key }
|
||||
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
|
||||
| { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key }
|
||||
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
|
||||
|
||||
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
|
||||
const all = current?.all ?? []
|
||||
|
|
@ -131,6 +132,14 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
|||
return { type: "draft", draftID }
|
||||
}
|
||||
|
||||
if (parts[0] === "server" && parts[2] === "session" && parts[3]) {
|
||||
return {
|
||||
type: "session",
|
||||
sessionId: parts[3],
|
||||
server: requireServerKey(parts[1]),
|
||||
}
|
||||
}
|
||||
|
||||
const dirBase64 = parts[0]
|
||||
const dir = decode64(dirBase64)
|
||||
if (!dir) return { type: "home" }
|
||||
|
|
@ -138,7 +147,7 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
|||
if (parts[1] !== "session") return { type: "home" }
|
||||
|
||||
const id = parts[2]
|
||||
if (id) return { type: "session", dir, dirBase64, sessionId: id }
|
||||
if (id) return { type: "session", sessionId: id }
|
||||
return { type: "dir-new-sesssion", dir, dirBase64 }
|
||||
}
|
||||
|
||||
|
|
@ -154,6 +163,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
const route = createMemo(() => {
|
||||
const value = currentRoute(location.pathname, location.search)
|
||||
if (value.type === "home") return value
|
||||
if (value.server) return value
|
||||
return { ...value, server: server.key }
|
||||
})
|
||||
|
||||
|
|
@ -572,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
handoff: {
|
||||
tabs: createMemo(() => store.handoff?.tabs),
|
||||
setTabs(dir: string, id: string) {
|
||||
setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() })
|
||||
setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() })
|
||||
},
|
||||
clearTabs() {
|
||||
if (!store.handoff?.tabs) return
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
|
|
@ -108,7 +108,7 @@ function buildNotificationIndex(list: Notification[]) {
|
|||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||
name: "Notification",
|
||||
gate: false,
|
||||
init: () => {
|
||||
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
|
||||
const params = useParams()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
|
|
@ -119,10 +119,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||
const empty: Notification[] = []
|
||||
|
||||
const currentDirectory = createMemo(() => {
|
||||
return decode64(params.dir)
|
||||
return props.directory?.() ?? decode64(params.dir)
|
||||
})
|
||||
|
||||
const currentSession = createMemo(() => params.id)
|
||||
const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
|
|
@ -47,13 +47,13 @@ function hasPermissionPromptRules(permission: unknown) {
|
|||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||
name: "Permission",
|
||||
gate: false,
|
||||
init: () => {
|
||||
init: (props: { directory?: Accessor<string | undefined> }) => {
|
||||
const params = useParams()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
|
||||
const permissionsEnabled = createMemo(() => {
|
||||
const directory = decode64(params.dir)
|
||||
const directory = props.directory?.() ?? decode64(params.dir)
|
||||
if (!directory) return false
|
||||
const [store] = serverSync().child(directory)
|
||||
return hasPermissionPromptRules(store.config.permission)
|
||||
|
|
@ -85,7 +85,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
// When config has permission: "allow", auto-enable directory-level auto-accept
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
const directory = decode64(params.dir)
|
||||
const directory = props.directory?.() ?? decode64(params.dir)
|
||||
if (!directory) return
|
||||
const [childStore] = serverSync().child(directory)
|
||||
const perm = childStore.config.permission
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
|
|
@ -7,6 +7,7 @@ import type { FileSelection } from "@/context/file"
|
|||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
|
|
@ -256,6 +257,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const serverSDK = useServerSDK()
|
||||
const cache = new Map<string, PromptCacheEntry>()
|
||||
|
|
@ -303,7 +305,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||
}
|
||||
|
||||
const session = createMemo(() =>
|
||||
load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }),
|
||||
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
|
||||
)
|
||||
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
|
|
@ -9,11 +8,11 @@ import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
|||
import { usePlatform } from "./platform"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
|
||||
export type SessionTab = {
|
||||
type: "session"
|
||||
server: ServerConnection.Key
|
||||
dirBase64: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
|
|
@ -34,16 +33,12 @@ type RecentTab = {
|
|||
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
|
||||
|
||||
export const tabHref = (tab: Tab) =>
|
||||
tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}`
|
||||
tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId)
|
||||
|
||||
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
|
||||
|
||||
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
|
||||
const dirBase64 = base64Encode(session.directory)
|
||||
return tabs.some(
|
||||
(tab) =>
|
||||
tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id,
|
||||
)
|
||||
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
|
||||
}
|
||||
|
||||
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
|
|
@ -105,14 +100,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||
const navigateTab = (tab: Tab) => {
|
||||
const href = tabHref(tab)
|
||||
setRecentKey(tabKey(tab))
|
||||
if (tab.server === server.key) {
|
||||
navigate(href)
|
||||
return
|
||||
}
|
||||
void startTransition(() => {
|
||||
server.setActive(tab.server)
|
||||
navigate(href)
|
||||
})
|
||||
navigate(href)
|
||||
}
|
||||
|
||||
const actions = {
|
||||
|
|
@ -195,11 +183,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||
const removed = store
|
||||
.filter(
|
||||
(tab) =>
|
||||
tab.type === "session" &&
|
||||
tab.server === server.key &&
|
||||
atob(tab.dirBase64) === input.directory &&
|
||||
input.sessionIDs.includes(tab.sessionId),
|
||||
(tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId),
|
||||
)
|
||||
.map(tabKey)
|
||||
void startTransition(() => {
|
||||
|
|
@ -211,7 +195,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||
? tabHref({
|
||||
type: "session",
|
||||
server: server.key,
|
||||
dirBase64: params.dir,
|
||||
sessionId: params.id,
|
||||
})
|
||||
: undefined
|
||||
|
|
@ -224,14 +207,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||
const removedCurrent =
|
||||
currentTab?.type === "session" &&
|
||||
currentTab.server === server.key &&
|
||||
atob(currentTab.dirBase64) === input.directory &&
|
||||
sessionIDs.has(currentTab.sessionId)
|
||||
|
||||
for (let i = tabs.length - 1; i >= 0; i--) {
|
||||
const tab = tabs[i]
|
||||
if (!tab || tab.type !== "session") continue
|
||||
if (tab.server !== server.key) continue
|
||||
if (atob(tab.dirBase64) !== input.directory) continue
|
||||
if (!sessionIDs.has(tab.sessionId)) continue
|
||||
tabs.splice(i, 1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
|
|||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK, type DirectorySDK } from "./sdk"
|
||||
import type { Platform } from "./platform"
|
||||
import { useServer } from "./server"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { defaultTitle, titleNumber } from "./terminal-title"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
|
||||
|
|
@ -374,10 +375,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||
gate: false,
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const serverSDK = useServerSDK()
|
||||
const params = useParams()
|
||||
const cache = new Map<string, TerminalCacheEntry>()
|
||||
const scope = server.scope()
|
||||
const scope = () => serverSDK().scope
|
||||
const directory = createMemo(() => base64Encode(sdk().directory))
|
||||
|
||||
caches.add(cache)
|
||||
onCleanup(() => caches.delete(cache))
|
||||
|
|
@ -421,11 +423,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||
return entry.value
|
||||
}
|
||||
|
||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope))
|
||||
const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope()))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ dir: params.dir, id: params.id, scope }),
|
||||
() => ({ dir: directory(), id: params.id, scope: scope() }),
|
||||
(next, prev) => {
|
||||
if (!prev?.dir) return
|
||||
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return
|
||||
|
|
|
|||
|
|
@ -2,26 +2,40 @@ import { DataProvider } from "@opencode-ai/ui/context"
|
|||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
|
||||
import { type Accessor, createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Schema } from "effect"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
|
||||
export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) {
|
||||
export function DirectoryDataProvider(
|
||||
props: ParentProps<{
|
||||
directory: string | Accessor<string>
|
||||
draftID?: string
|
||||
server?: Accessor<ServerConnection.Key | undefined>
|
||||
}>,
|
||||
) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
|
||||
const slug = createMemo(() => base64Encode(directory()))
|
||||
const href = (sessionID: string) => {
|
||||
const server = props.server?.()
|
||||
if (server) return sessionHref(server, sessionID)
|
||||
return `/${slug()}/session/${sessionID}`
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
|
||||
if (props.draftID) return
|
||||
if (props.draftID || props.server?.()) return
|
||||
const next = sync().data.path.directory
|
||||
if (!next || next === props.directory) return
|
||||
if (!next || next === directory()) return
|
||||
const path = location.pathname.slice(slug().length + 1)
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
})
|
||||
|
|
@ -37,9 +51,9 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
|
|||
return (
|
||||
<DataProvider
|
||||
data={sync().data}
|
||||
directory={props.directory}
|
||||
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
|
||||
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
|
||||
directory={directory()}
|
||||
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ import { sessionTitle } from "@/utils/session-title"
|
|||
import { pathKey } from "@/utils/path-key"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
|
|
@ -113,16 +112,7 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
|
|||
return `${pathKey(record.session.directory)}:${record.session.id}`
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
|
||||
<HomeDesign />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeDesign() {
|
||||
export function NewHome() {
|
||||
const sync = useServerSync()
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
|
|
@ -313,7 +303,7 @@ function HomeDesign() {
|
|||
const ctx = global.createServerCtx(conn)
|
||||
ctx.projects.open(directory)
|
||||
ctx.projects.touch(directory)
|
||||
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
|
||||
navigateOnServer(conn, `/server/${base64Encode(ServerConnection.key(conn))}/session/${session.id}`)
|
||||
}
|
||||
|
||||
function chooseProject(conn: ServerConnection.Any) {
|
||||
|
|
@ -416,7 +406,7 @@ function HomeDesign() {
|
|||
record={record}
|
||||
server={state.selection.server}
|
||||
activeServer={state.selection.server === server.key}
|
||||
openSession={openSession}
|
||||
onClick={() => openSession(record.session)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -1046,7 +1036,7 @@ function HomeSessionRow(props: {
|
|||
record: HomeSessionRecord
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
openSession: (session: Session) => void
|
||||
onClick: () => void
|
||||
}) {
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
|
||||
|
|
@ -1055,7 +1045,7 @@ function HomeSessionRow(props: {
|
|||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
|
||||
onClick={() => props.openSession(props.record.session)}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
|
|
@ -1115,7 +1105,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
|
|||
].filter((group) => group.sessions.length > 0)
|
||||
}
|
||||
|
||||
function LegacyHome() {
|
||||
export function LegacyHome() {
|
||||
const sync = useServerSync()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
|
|
|
|||
38
packages/app/src/pages/layout-new.tsx
Normal file
38
packages/app/src/pages/layout-new.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { createEffect, type ParentProps } from "solid-js"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { HelpButton } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||
|
||||
export default function NewLayout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const navigate = useNavigate()
|
||||
setNavigate(navigate)
|
||||
|
||||
createEffect(() => setV2Toast(true))
|
||||
|
||||
const update: TitlebarUpdate = {
|
||||
version: () => {
|
||||
const state = platform.updater?.state()
|
||||
if (state?.status !== "ready") return
|
||||
return state.version
|
||||
},
|
||||
installing: () => platform.updater?.state().status === "installing",
|
||||
install: () => void platform.updater?.install(),
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
<Titlebar update={update} />
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
{props.children}
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<HelpButton />
|
||||
<ToastRegion v2 />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
type Accessor,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useLayout, LocalProject } from "@/context/layout"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
|
@ -92,7 +92,7 @@ import {
|
|||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||
import { SidebarContent } from "./layout/sidebar-shell"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
export default function LegacyLayout(props: ParentProps) {
|
||||
const serverSDK = useServerSDK()
|
||||
const [store, setStore, , ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
|
||||
|
|
@ -131,10 +131,8 @@ export default function Layout(props: ParentProps) {
|
|||
const command = useCommand()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
createEffect(() => setV2Toast(newDesign()))
|
||||
createEffect(() => setV2Toast(false))
|
||||
const initialDirectory = decode64(params.dir)
|
||||
const location = useLocation()
|
||||
const route = createMemo(() => {
|
||||
const slug = params.dir
|
||||
if (!slug) return { slug, dir: "" }
|
||||
|
|
@ -158,7 +156,7 @@ export default function Layout(props: ParentProps) {
|
|||
const currentDir = createMemo(() => route().dir)
|
||||
|
||||
const [state, setState] = createStore({
|
||||
autoselect: !initialDirectory && !newDesign(),
|
||||
autoselect: !initialDirectory,
|
||||
busyWorkspaces: {} as Record<string, boolean>,
|
||||
hoverProject: undefined as string | undefined,
|
||||
scrollSessionKey: undefined as string | undefined,
|
||||
|
|
@ -996,7 +994,7 @@ export default function Layout(props: ParentProps) {
|
|||
id: "sidebar.toggle",
|
||||
title: language.t("command.sidebar.toggle"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: newDesign() ? undefined : "mod+b",
|
||||
keybind: "mod+b",
|
||||
onSelect: () => layout.sidebar.toggle(),
|
||||
},
|
||||
{
|
||||
|
|
@ -1134,20 +1132,19 @@ export default function Layout(props: ParentProps) {
|
|||
},
|
||||
]
|
||||
|
||||
if (!newDesign())
|
||||
Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
commands.push({
|
||||
id: `project.${number}`,
|
||||
category: language.t("command.category.project"),
|
||||
title: `Open Project {number}`,
|
||||
keybind: `mod+${number}`,
|
||||
disabled: layout.projects.list().length <= index,
|
||||
hidden: true,
|
||||
onSelect: () => navigateToProjectIndex(index),
|
||||
})
|
||||
Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
commands.push({
|
||||
id: `project.${number}`,
|
||||
category: language.t("command.category.project"),
|
||||
title: `Open Project {number}`,
|
||||
keybind: `mod+${number}`,
|
||||
disabled: layout.projects.list().length <= index,
|
||||
hidden: true,
|
||||
onSelect: () => navigateToProjectIndex(index),
|
||||
})
|
||||
})
|
||||
|
||||
for (const [id] of availableThemeEntries()) {
|
||||
commands.push({
|
||||
|
|
@ -1812,7 +1809,7 @@ export default function Layout(props: ParentProps) {
|
|||
createEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--dialog-left-margin",
|
||||
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
`${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -2355,176 +2352,158 @@ export default function Layout(props: ParentProps) {
|
|||
)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!newDesign()}
|
||||
fallback={
|
||||
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||
<HelpButton />
|
||||
<ToastRegion v2={newDesign()} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<Show when={updateVersion() !== undefined}>
|
||||
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="size-full relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${side()}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<Show when={updateVersion() !== undefined}>
|
||||
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="size-full relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${side()}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
</nav>
|
||||
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
</nav>
|
||||
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebarContent(true)}
|
||||
</nav>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!state.sizing,
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
}}
|
||||
>
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
/>
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebarContent(true)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!state.sizing,
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={peekProject()}>
|
||||
<SidebarPanel project={peekProject} merged={false} />
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peekProject()}>
|
||||
<SidebarPanel project={peekProject} merged={false} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
</div>
|
||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||
</div>
|
||||
<HelpButton />
|
||||
<ToastRegion v2={newDesign()} />
|
||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||
</div>
|
||||
</Show>
|
||||
<HelpButton />
|
||||
<ToastRegion v2={false} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
|||
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
|
|
@ -56,7 +56,6 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
|
|||
import { createTimelineModel } from "@/pages/session/timeline/model"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { useServer } from "@/context/server"
|
||||
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
|
|
@ -92,7 +91,6 @@ export default function Page() {
|
|||
const prompt = usePrompt()
|
||||
const comments = useComments()
|
||||
const terminal = useTerminal()
|
||||
const server = useServer()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
const location = useLocation()
|
||||
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
|
||||
|
|
@ -137,11 +135,11 @@ export default function Page() {
|
|||
layout.handoff.clearTabs()
|
||||
return
|
||||
}
|
||||
if (pending.scope !== server.scope()) return
|
||||
if (pending.scope !== serverSDK().scope) return
|
||||
|
||||
if (pending.id !== id) return
|
||||
layout.handoff.clearTabs()
|
||||
if (pending.dir !== (params.dir ?? "")) return
|
||||
if (pending.dir !== base64Encode(sdk().directory)) return
|
||||
|
||||
const from = workspaceTabs().tabs()
|
||||
if (from.all.length === 0 && !from.active) return
|
||||
|
|
@ -247,7 +245,7 @@ export default function Page() {
|
|||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ dir: params.dir, id: params.id }),
|
||||
() => ({ dir: sdk().directory, id: params.id }),
|
||||
(next, prev) => {
|
||||
if (!prev) return
|
||||
if (next.dir === prev.dir && next.id === prev.id) return
|
||||
|
|
@ -575,7 +573,7 @@ export default function Page() {
|
|||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.dir,
|
||||
() => sdk().directory,
|
||||
(dir) => {
|
||||
if (!dir) return
|
||||
setStore("newSessionWorktree", "main")
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { useServer } from "@/context/server"
|
|||
import { useTabs } from "@/context/tabs"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
|
|
@ -200,7 +201,11 @@ export function SessionComposerRegion(props: {
|
|||
const openParent = () => {
|
||||
const id = parentID()
|
||||
if (!id) return
|
||||
navigate(`/${route.params.dir}/session/${id}`)
|
||||
navigate(
|
||||
route.params.serverKey
|
||||
? sessionHref(requireServerKey(route.params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { useParams } from "@solidjs/router"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useServer } from "@/context/server"
|
||||
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
|
||||
export const useSessionKey = () => {
|
||||
const params = useParams()
|
||||
const server = useServer()
|
||||
const scope = createMemo(() => server.scope())
|
||||
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir)))
|
||||
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir, params.id)))
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
const scope = createMemo(() => serverSDK().scope)
|
||||
const directory = createMemo(() => base64Encode(sdk().directory))
|
||||
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory())))
|
||||
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory(), params.id)))
|
||||
return { params, sessionKey, workspaceKey }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
|
|||
import { useLayout } from "@/context/layout"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
||||
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||
|
|
@ -25,10 +26,11 @@ export function TerminalPanel() {
|
|||
const delays = [120, 240]
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const settings = useSettings()
|
||||
const { params, workspaceKey, view } = useSessionLayout()
|
||||
const { workspaceKey, view } = useSessionLayout()
|
||||
|
||||
const opened = createMemo(() => view().terminal.opened())
|
||||
const size = createSizing()
|
||||
|
|
@ -122,7 +124,7 @@ export function TerminalPanel() {
|
|||
})
|
||||
|
||||
createEffect(() => {
|
||||
const dir = params.dir
|
||||
const dir = sdk().directory
|
||||
if (!dir) return
|
||||
if (!terminal.ready()) return
|
||||
language.locale()
|
||||
|
|
@ -140,7 +142,7 @@ export function TerminalPanel() {
|
|||
})
|
||||
|
||||
const handoff = createMemo(() => {
|
||||
const dir = params.dir
|
||||
const dir = sdk().directory
|
||||
if (!dir) return []
|
||||
return getTerminalHandoff(workspaceKey()) ?? []
|
||||
})
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ import { useSessionKey } from "@/pages/session/session-layout"
|
|||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
|
|
@ -260,6 +262,7 @@ export function MessageTimeline(props: {
|
|||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const { params, sessionKey } = useSessionKey()
|
||||
|
|
@ -757,12 +760,18 @@ export function MessageTimeline(props: {
|
|||
|
||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||
if (params.id !== sessionID) return
|
||||
const href = (id: string) =>
|
||||
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
|
||||
if (parentID) {
|
||||
navigate(`/${params.dir}/session/${parentID}`)
|
||||
navigate(href(parentID))
|
||||
return
|
||||
}
|
||||
if (nextSessionID) {
|
||||
navigate(`/${params.dir}/session/${nextSessionID}`)
|
||||
navigate(href(nextSessionID))
|
||||
return
|
||||
}
|
||||
if (params.serverKey) {
|
||||
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
|
||||
return
|
||||
}
|
||||
navigate(`/${params.dir}/session`)
|
||||
|
|
@ -864,7 +873,9 @@ export function MessageTimeline(props: {
|
|||
const navigateParent = () => {
|
||||
const id = parentID()
|
||||
if (!id) return
|
||||
navigate(`/${params.dir}/session/${id}`)
|
||||
navigate(
|
||||
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { createSessionTabs } from "@/pages/session/helpers"
|
|||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
|
|
@ -45,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
const settings = useSettings()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const sessionTabs = useTabs()
|
||||
const layout = useLayout()
|
||||
const navigate = useNavigate()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
|
@ -381,7 +384,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
title: language.t("command.session.new"),
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
onSelect: () => navigate(`/${params.dir}/session`),
|
||||
onSelect: () => {
|
||||
if (params.serverKey) {
|
||||
sessionTabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
|
||||
return
|
||||
}
|
||||
navigate(`/${params.dir}/session`)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.undo",
|
||||
|
|
|
|||
39
packages/app/src/utils/session-route.test.ts
Normal file
39
packages/app/src/utils/session-route.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route"
|
||||
|
||||
describe("session routes", () => {
|
||||
test("builds and decodes a server-keyed session route", () => {
|
||||
const server = ServerConnection.Key.make("https://example.com:4096")
|
||||
const href = sessionHref(server, "session-1")
|
||||
|
||||
expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/session-1")
|
||||
expect(requireServerKey(href.split("/")[2])).toBe(server)
|
||||
})
|
||||
|
||||
test("rejects malformed server keys", () => {
|
||||
expect(() => requireServerKey("not-base64")).toThrow("Invalid server route")
|
||||
})
|
||||
|
||||
test("builds the legacy directory-keyed route", () => {
|
||||
expect(legacySessionHref("/Users/example/project", "session-1")).toBe(
|
||||
"/L1VzZXJzL2V4YW1wbGUvcHJvamVjdA/session/session-1",
|
||||
)
|
||||
})
|
||||
|
||||
test("resolves the root session", async () => {
|
||||
const sessions: Record<string, { id: string; parentID?: string }> = {
|
||||
child: { id: "child", parentID: "parent" },
|
||||
parent: { id: "parent", parentID: "root" },
|
||||
root: { id: "root" },
|
||||
}
|
||||
|
||||
expect(
|
||||
await rootSession(sessions.child, async (id) => {
|
||||
const session = sessions[id]
|
||||
if (!session) throw new Error(`Missing session: ${id}`)
|
||||
return session
|
||||
}),
|
||||
).toBe(sessions.root)
|
||||
})
|
||||
})
|
||||
25
packages/app/src/utils/session-route.ts
Normal file
25
packages/app/src/utils/session-route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
export function sessionHref(server: ServerConnection.Key, sessionID: string) {
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
export function legacySessionHref(directory: string, sessionID: string) {
|
||||
return `/${base64Encode(directory)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
export function requireServerKey(segment: string | undefined) {
|
||||
const key = decode64(segment)
|
||||
if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route")
|
||||
return ServerConnection.Key.make(key)
|
||||
}
|
||||
|
||||
type SessionParent = { id: string; parentID?: string }
|
||||
|
||||
export async function rootSession(session: SessionParent, get: (sessionID: string) => Promise<SessionParent>) {
|
||||
let current = session
|
||||
while (current.parentID) current = await get(current.parentID)
|
||||
return current
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -27,9 +27,11 @@ export default Runtime.handler(
|
|||
|
||||
function listen(hostname: string, port: Option.Option<number>, password: string) {
|
||||
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
||||
// Preserve the familiar default when available, but let the OS choose a free
|
||||
// port when another local server already owns 4096.
|
||||
return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password)))
|
||||
const next = (port: number): ReturnType<typeof bind> =>
|
||||
bind(hostname, port, password).pipe(
|
||||
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
|
||||
)
|
||||
return next(4096)
|
||||
}
|
||||
|
||||
function bind(hostname: string, port: number, password: string) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -157,9 +157,6 @@ export async function handler(
|
|||
logger.metric({
|
||||
provider: providerInfo.id,
|
||||
"provider.model": providerInfo.model,
|
||||
...(providerBudgetUsage?.[providerInfo.id]
|
||||
? { "provider.budget_usage": providerBudgetUsage?.[providerInfo.id] }
|
||||
: {}),
|
||||
})
|
||||
|
||||
const startTimestamp = Date.now()
|
||||
|
|
@ -199,6 +196,13 @@ export async function handler(
|
|||
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
|
||||
headers.set(k, headers.get(v)!)
|
||||
})
|
||||
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
|
||||
if (v === "$ip") return headers.set(k, ip)
|
||||
if (v === "$session") return headers.set(k, sessionId)
|
||||
if (v === "$model") return headers.set(k, model)
|
||||
if (v === "$request") return headers.set(k, requestId)
|
||||
headers.set(k, v)
|
||||
})
|
||||
headers.delete("host")
|
||||
headers.delete("content-length")
|
||||
headers.delete("x-opencode-request")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||
import { buildRateLimitKey, getRedis } from "./redis"
|
||||
import { logger } from "./logger"
|
||||
|
||||
export function createProviderBudgetTracker(
|
||||
providers: {
|
||||
|
|
@ -22,13 +23,15 @@ export function createProviderBudgetTracker(
|
|||
const keys = Object.fromEntries(
|
||||
tracked.map((provider) => [provider.id, buildRateLimitKey("provider-budget", provider.id, interval)]),
|
||||
)
|
||||
let budgetUsage: Record<string, number> = {}
|
||||
|
||||
return {
|
||||
check: async () => {
|
||||
const ids = tracked.filter((provider) => provider.budgetMode === "fill").map((provider) => provider.id)
|
||||
const ids = tracked.map((provider) => provider.id)
|
||||
if (ids.length === 0) return {}
|
||||
const values = await redis.mget<(string | number | null)[]>(ids.map((id) => keys[id]))
|
||||
return Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)]))
|
||||
budgetUsage = Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)]))
|
||||
return budgetUsage
|
||||
},
|
||||
track: async (provider: string, costInCent: number) => {
|
||||
const config = tracked.find((item) => item.id === provider)
|
||||
|
|
@ -40,6 +43,10 @@ export function createProviderBudgetTracker(
|
|||
pipeline.incrby(keys[provider], cost)
|
||||
pipeline.expire(keys[provider], 120)
|
||||
await pipeline.exec()
|
||||
logger.metric({
|
||||
"provider.budget_usage": budgetUsage[provider] + cost,
|
||||
"model.budget_usage": cost,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export namespace ZenData {
|
|||
apiKey: z.union([z.string(), z.record(z.string(), z.string())]),
|
||||
format: FormatSchema.optional(),
|
||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
headerModifier: z.record(z.string(), z.any()).optional(),
|
||||
payloadModifier: z.record(z.string(), z.any()).optional(),
|
||||
payloadMappings: z.record(z.string(), z.string()).optional(),
|
||||
adjustCacheUsage: z.boolean().optional(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
2
packages/core/bunfig.toml
Normal file
2
packages/core/bunfig.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[test]
|
||||
preload = ["./test/preload.ts"]
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.17.8",
|
||||
"version": "1.17.9",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
@ -91,6 +91,7 @@
|
|||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
|
||||
"prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"],
|
||||
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
|
||||
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "workspace",
|
||||
|
|
@ -900,16 +900,6 @@
|
|||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'build'",
|
||||
"generated": null,
|
||||
"name": "agent",
|
||||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
|
|
@ -930,26 +920,6 @@
|
|||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "replacement_seq",
|
||||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "revision",
|
||||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
|
|
|
|||
|
|
@ -45,15 +45,17 @@ async function generate() {
|
|||
if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`)
|
||||
await Bun.write(
|
||||
target,
|
||||
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
|
||||
await formatTypescript(
|
||||
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
|
||||
),
|
||||
)
|
||||
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
|
||||
}
|
||||
|
||||
await fs.mkdir(full)
|
||||
await drizzle(temporary, full, "schema")
|
||||
await Bun.write(schema, renderSchema(await generatedSql(full)))
|
||||
await Bun.write(registry, renderRegistry(await typescriptMigrations()))
|
||||
await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full))))
|
||||
await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations())))
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
|
|
@ -76,12 +78,12 @@ async function check() {
|
|||
|
||||
await fs.mkdir(full)
|
||||
await drizzle(temporary, full, "schema")
|
||||
if ((await Bun.file(schema).text()) !== renderSchema(await generatedSql(full))) {
|
||||
if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) {
|
||||
throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.")
|
||||
}
|
||||
|
||||
const migrations = await typescriptMigrations()
|
||||
if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) {
|
||||
if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) {
|
||||
throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -170,6 +172,18 @@ function escapeTemplate(line: string) {
|
|||
return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
|
||||
}
|
||||
|
||||
async function formatTypescript(input: string) {
|
||||
const prettier = await import("prettier")
|
||||
const typescript = await import("prettier/plugins/typescript")
|
||||
const estree = await import("prettier/plugins/estree")
|
||||
return prettier.format(input, {
|
||||
parser: "typescript",
|
||||
plugins: [typescript.default, estree.default],
|
||||
semi: false,
|
||||
printWidth: 120,
|
||||
})
|
||||
}
|
||||
|
||||
function renderRegistry(names: string[]) {
|
||||
return `import type { DatabaseMigration } from "./migration"
|
||||
|
||||
|
|
|
|||
|
|
@ -35,19 +35,19 @@ export class Org extends Schema.Class<Org>("Org")({
|
|||
|
||||
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export class AccountTransportError extends Schema.TaggedErrorClass<AccountTransportError>()("AccountTransportError", {
|
||||
method: Schema.String,
|
||||
url: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {
|
||||
static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError {
|
||||
return new AccountTransportError({
|
||||
|
|
@ -94,7 +94,7 @@ export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired"
|
|||
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
|
||||
|
||||
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
|
||||
cause: Schema.Defect,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
export * as AgentV2 from "./agent"
|
||||
|
||||
import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { castDraft, enableMapSet, type Draft } from "immer"
|
||||
import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { PermissionSchema } from "./permission/schema"
|
||||
import { ProviderV2 } from "./provider"
|
||||
|
|
@ -49,21 +48,19 @@ export interface Selection {
|
|||
}
|
||||
|
||||
type Data = {
|
||||
agents: Map<ID, Info>
|
||||
agents: Map<ID, Types.DeepMutable<Info>>
|
||||
default?: ID
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
list: () => readonly Info[]
|
||||
get: (id: ID) => Info | undefined
|
||||
default: (id: ID | undefined) => void
|
||||
update: (id: ID, fn: (agent: Draft<Info>) => void) => void
|
||||
update: (id: ID, fn: (agent: Types.DeepMutable<Info>) => void) => void
|
||||
remove: (id: ID) => void
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
readonly default: () => Effect.Effect<Info | undefined>
|
||||
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
|
||||
|
|
@ -73,21 +70,19 @@ export interface Interface {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
|
||||
|
||||
enableMapSet()
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ agents: new Map() }),
|
||||
editor: (draft) => ({
|
||||
draft: (draft) => ({
|
||||
list: () => Array.fromIterable(draft.agents.values()) as Info[],
|
||||
get: (id) => draft.agents.get(id),
|
||||
default: (id) => {
|
||||
draft.default = id
|
||||
},
|
||||
update: (id, fn) => {
|
||||
const current = draft.agents.get(id) ?? castDraft(Info.empty(id))
|
||||
const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable<Info>)
|
||||
if (!draft.agents.has(id)) draft.agents.set(id, current)
|
||||
fn(current)
|
||||
current.id = id
|
||||
|
|
@ -113,7 +108,7 @@ export const layer = Layer.effect(
|
|||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
update: state.update,
|
||||
reload: state.reload,
|
||||
get: Effect.fn("AgentV2.get")(function* (id) {
|
||||
return state.get().agents.get(id)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
export * as AISDK from "./aisdk"
|
||||
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Cause, Context, Effect, Layer, Schema } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { EventV2 } from "./event"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { State } from "./state"
|
||||
|
||||
type SDK = any
|
||||
|
||||
export interface SDKEvent {
|
||||
readonly model: ModelV2.Info
|
||||
readonly package: string
|
||||
readonly options: Record<string, any>
|
||||
sdk?: SDK
|
||||
}
|
||||
|
||||
export interface LanguageEvent {
|
||||
readonly model: ModelV2.Info
|
||||
readonly sdk: SDK
|
||||
readonly options: Record<string, any>
|
||||
language?: LanguageModelV3
|
||||
}
|
||||
|
||||
function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
if (typeof ms !== "number" || ms <= 0) return res
|
||||
if (!res.body) return res
|
||||
|
|
@ -109,7 +122,7 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
|
|||
|
||||
export class InitError extends Schema.TaggedErrorClass<InitError>()("AISDK.InitError", {
|
||||
providerID: ProviderV2.ID,
|
||||
cause: Schema.Defect,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
function initError(providerID: ProviderV2.ID) {
|
||||
|
|
@ -117,19 +130,70 @@ function initError(providerID: ProviderV2.ID) {
|
|||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly hook: {
|
||||
readonly sdk: (
|
||||
callback: (event: SDKEvent) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly language: (
|
||||
callback: (event: LanguageEvent) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
}
|
||||
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
|
||||
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
|
||||
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
export const locationLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
let sdkHooks: ((event: SDKEvent) => Effect.Effect<void> | void)[] = []
|
||||
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
|
||||
const languages = new Map<string, LanguageModelV3>()
|
||||
const sdks = new Map<string, SDK>()
|
||||
|
||||
return Service.of({
|
||||
const register = <Event>(
|
||||
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
|
||||
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
|
||||
) =>
|
||||
Effect.fn("AISDK.hook")(function* (callback: (event: Event) => Effect.Effect<void> | void) {
|
||||
const scope = yield* Scope.Scope
|
||||
let active = true
|
||||
update([...hooks(), callback])
|
||||
const dispose = Effect.sync(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
update(hooks().filter((item) => item !== callback))
|
||||
})
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
return { dispose }
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* <Event>(
|
||||
hooks: readonly ((event: Event) => Effect.Effect<void> | void)[],
|
||||
event: Event,
|
||||
) {
|
||||
for (const hook of hooks) {
|
||||
const result = hook(event)
|
||||
if (Effect.isEffect(result)) yield* result
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
const service = Service.of({
|
||||
hook: {
|
||||
sdk: register(
|
||||
() => sdkHooks,
|
||||
(next) => (sdkHooks = next),
|
||||
),
|
||||
language: register(
|
||||
() => languageHooks,
|
||||
(next) => (languageHooks = next),
|
||||
),
|
||||
},
|
||||
runSDK: (event) => run(sdkHooks, event),
|
||||
runLanguage: (event) => run(languageHooks, event),
|
||||
language: Effect.fn("AISDK.language")(function* (model) {
|
||||
const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}`
|
||||
const existing = languages.get(key)
|
||||
|
|
@ -148,26 +212,14 @@ export const layer = Layer.effect(
|
|||
})
|
||||
const sdk =
|
||||
sdks.get(sdkKey) ??
|
||||
(yield* plugin
|
||||
.trigger("aisdk.sdk", { model, package: model.api.package, options }, {})
|
||||
.pipe(initError(model.providerID))).sdk
|
||||
(yield* service.runSDK({ model, package: model.api.package, options }).pipe(initError(model.providerID))).sdk
|
||||
if (!sdk)
|
||||
return yield* new InitError({
|
||||
providerID: model.providerID,
|
||||
cause: new Error("No AISDK provider plugin returned an SDK"),
|
||||
})
|
||||
sdks.set(sdkKey, sdk)
|
||||
const result = yield* plugin
|
||||
.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model,
|
||||
sdk,
|
||||
options,
|
||||
},
|
||||
{},
|
||||
)
|
||||
.pipe(initError(model.providerID))
|
||||
const result = yield* service.runLanguage({ model, sdk, options }).pipe(initError(model.providerID))
|
||||
const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe(
|
||||
initError(model.providerID),
|
||||
)
|
||||
|
|
@ -175,7 +227,8 @@ export const layer = Layer.effect(
|
|||
return language
|
||||
}),
|
||||
})
|
||||
return service
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))))
|
||||
export const defaultLayer = locationLayer
|
||||
|
|
|
|||
|
|
@ -1,36 +1,21 @@
|
|||
export * as Catalog from "./catalog"
|
||||
|
||||
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema, Scope, Stream } from "effect"
|
||||
import { castDraft, enableMapSet, type Draft } from "immer"
|
||||
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { ModelRequest } from "./model-request"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { Location } from "./location"
|
||||
import { EventV2 } from "./event"
|
||||
import { Policy } from "./policy"
|
||||
import { State } from "./state"
|
||||
import { Integration } from "./integration"
|
||||
|
||||
export type ProviderRecord = {
|
||||
provider: ProviderV2.Info
|
||||
models: Map<ModelV2.ID, ModelV2.Info>
|
||||
provider: ProviderV2.MutableInfo
|
||||
models: Map<ModelV2.ID, ModelV2.MutableInfo>
|
||||
}
|
||||
|
||||
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
||||
|
||||
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
|
||||
"CatalogV2.ProviderNotFound",
|
||||
{
|
||||
providerID: ProviderV2.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("CatalogV2.ModelNotFound", {
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
}) {}
|
||||
|
||||
export const PolicyActions = Schema.Literals(["provider.use"])
|
||||
|
||||
export const Event = {
|
||||
|
|
@ -42,16 +27,16 @@ type Data = {
|
|||
defaultModel?: DefaultModel
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
provider: {
|
||||
list: () => readonly ProviderRecord[]
|
||||
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
|
||||
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
|
||||
update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void
|
||||
remove: (providerID: ProviderV2.ID) => void
|
||||
}
|
||||
model: {
|
||||
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
|
||||
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
|
||||
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void
|
||||
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
|
||||
default: {
|
||||
get: () => DefaultModel | undefined
|
||||
|
|
@ -60,43 +45,34 @@ export type Editor = {
|
|||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly provider: {
|
||||
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
|
||||
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
|
||||
readonly all: () => Effect.Effect<ProviderV2.Info[]>
|
||||
readonly available: () => Effect.Effect<ProviderV2.Info[]>
|
||||
}
|
||||
readonly model: {
|
||||
readonly get: (
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
|
||||
readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined>
|
||||
readonly all: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly available: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
|
||||
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>>
|
||||
readonly default: () => Effect.Effect<ModelV2.Info | undefined>
|
||||
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
|
||||
|
||||
enableMapSet()
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const policy = yield* Policy.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.request.body.apiKey === "string") return true
|
||||
if (connected) return true
|
||||
if (integration?.connections.length) return true
|
||||
return !integration
|
||||
}
|
||||
|
||||
|
|
@ -120,32 +96,26 @@ export const layer = Layer.effect(
|
|||
})
|
||||
}
|
||||
|
||||
function* getRecord(providerID: ProviderV2.ID) {
|
||||
const match = state.get().providers.get(providerID)
|
||||
if (!match) return yield* new ProviderNotFoundError({ providerID })
|
||||
return match
|
||||
}
|
||||
|
||||
const normalizeApi = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
|
||||
const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => {
|
||||
if (typeof item.request.body.baseURL !== "string") return
|
||||
item.api.url = item.request.body.baseURL
|
||||
delete item.request.body.baseURL
|
||||
}
|
||||
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
editor: (draft) => {
|
||||
const result: Editor = {
|
||||
draft: (draft) => {
|
||||
const result: Draft = {
|
||||
provider: {
|
||||
list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[],
|
||||
get: (providerID) => draft.providers.get(providerID),
|
||||
update: (providerID, fn) => {
|
||||
let current = draft.providers.get(providerID)
|
||||
if (!current) {
|
||||
current = castDraft({
|
||||
provider: ProviderV2.Info.empty(providerID),
|
||||
models: new Map<ModelV2.ID, ModelV2.Info>(),
|
||||
})
|
||||
current = {
|
||||
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
|
||||
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
|
||||
}
|
||||
draft.providers.set(providerID, current)
|
||||
}
|
||||
fn(current.provider)
|
||||
|
|
@ -160,13 +130,14 @@ export const layer = Layer.effect(
|
|||
update: (providerID, modelID, fn) => {
|
||||
let record = draft.providers.get(providerID)
|
||||
if (!record) {
|
||||
record = castDraft({
|
||||
provider: ProviderV2.Info.empty(providerID),
|
||||
models: new Map<ModelV2.ID, ModelV2.Info>(),
|
||||
})
|
||||
record = {
|
||||
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
|
||||
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
|
||||
}
|
||||
draft.providers.set(providerID, record)
|
||||
}
|
||||
const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID))
|
||||
const model =
|
||||
record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo)
|
||||
if (!record.models.has(modelID)) record.models.set(modelID, model)
|
||||
fn(model)
|
||||
model.id = modelID
|
||||
|
|
@ -186,8 +157,7 @@ export const layer = Layer.effect(
|
|||
}
|
||||
return result
|
||||
},
|
||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
|
||||
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
|
||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
|
||||
if (policy.hasStatements()) {
|
||||
for (const record of [...catalog.provider.list()]) {
|
||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||
|
|
@ -198,25 +168,13 @@ export const layer = Layer.effect(
|
|||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
yield* events.subscribe(PluginV2.Event.Added).pipe(
|
||||
// Plugin registries are location scoped even though the event bus is process scoped.
|
||||
Stream.filter(
|
||||
(event) =>
|
||||
event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
|
||||
),
|
||||
Stream.runForEach((event) =>
|
||||
state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
|
||||
const result: Interface = {
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
|
||||
provider: {
|
||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||
const record = yield* getRecord(providerID)
|
||||
return record.provider
|
||||
return state.get().providers.get(providerID)?.provider
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.provider.all")(function* () {
|
||||
|
|
@ -225,23 +183,18 @@ export const layer = Layer.effect(
|
|||
|
||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
const connections = yield* integrations.connection.list()
|
||||
return (yield* result.provider.all()).filter((provider) =>
|
||||
available(
|
||||
provider,
|
||||
active.get(Integration.ID.make(provider.id)),
|
||||
connections.has(Integration.ID.make(provider.id)),
|
||||
),
|
||||
available(provider, active.get(Integration.ID.make(provider.id))),
|
||||
)
|
||||
}),
|
||||
},
|
||||
|
||||
model: {
|
||||
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
|
||||
const record = yield* getRecord(providerID)
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return
|
||||
const model = record.models.get(modelID)
|
||||
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
|
||||
return projectModel(model, record.provider)
|
||||
return model && projectModel(model, record.provider)
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.model.all")(function* () {
|
||||
|
|
@ -250,7 +203,7 @@ export const layer = Layer.effect(
|
|||
Array.flatMap((record) => {
|
||||
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
|
||||
}),
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
|
||||
)
|
||||
}),
|
||||
|
||||
|
|
@ -262,31 +215,30 @@ export const layer = Layer.effect(
|
|||
default: Effect.fn("CatalogV2.model.default")(function* () {
|
||||
const defaultModel = state.get().defaultModel
|
||||
if (defaultModel) {
|
||||
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
|
||||
if (
|
||||
Option.isSome(provider) &&
|
||||
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
|
||||
) {
|
||||
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
|
||||
if (Option.isSome(model) && model.value.enabled) return model
|
||||
const provider = yield* result.provider.get(defaultModel.providerID)
|
||||
if (provider && (yield* result.provider.available()).some((item) => item.id === provider.id)) {
|
||||
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID)
|
||||
if (model?.enabled) return model
|
||||
}
|
||||
}
|
||||
|
||||
return pipe(
|
||||
yield* result.model.available(),
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
Array.head,
|
||||
return Option.getOrUndefined(
|
||||
pipe(
|
||||
yield* result.model.available(),
|
||||
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
|
||||
Array.head,
|
||||
),
|
||||
)
|
||||
}),
|
||||
|
||||
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return Option.none<ModelV2.Info>()
|
||||
if (!record) return
|
||||
const provider = record.provider
|
||||
|
||||
if (providerID === ProviderV2.ID.opencode) {
|
||||
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
|
||||
}
|
||||
|
||||
const candidates = pipe(
|
||||
|
|
@ -302,7 +254,7 @@ export const layer = Layer.effect(
|
|||
Array.map((model) => ({
|
||||
model,
|
||||
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
|
||||
age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30),
|
||||
age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30),
|
||||
small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
|
||||
})),
|
||||
Array.filter((item) => item.cost > 0 && item.age <= 18),
|
||||
|
|
@ -319,10 +271,12 @@ export const layer = Layer.effect(
|
|||
)
|
||||
}
|
||||
|
||||
return pipe(
|
||||
candidates,
|
||||
Array.filter((item) => item.small),
|
||||
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
|
||||
return Option.getOrUndefined(
|
||||
pipe(
|
||||
candidates,
|
||||
Array.filter((item) => item.small),
|
||||
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
|
|
@ -336,6 +290,5 @@ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
|||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(PluginV2.locationLayer),
|
||||
Layer.provideMerge(Policy.locationLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
export * as CommandV2 from "./command"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { castDraft, type Draft } from "immer"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { State } from "./state"
|
||||
|
||||
|
|
@ -15,19 +14,17 @@ export class Info extends Schema.Class<Info>("CommandV2.Info")({
|
|||
}) {}
|
||||
|
||||
export type Data = {
|
||||
commands: Map<string, Info>
|
||||
commands: Map<string, Types.DeepMutable<Info>>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
list: () => readonly Info[]
|
||||
get: (name: string) => Info | undefined
|
||||
update: (name: string, update: (command: Draft<Info>) => void) => void
|
||||
update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
|
||||
remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
|
@ -37,13 +34,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.sync(() => {
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ commands: new Map() }),
|
||||
editor: (draft) => ({
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.commands.values()) as Info[],
|
||||
get: (name) => draft.commands.get(name),
|
||||
update: (name, update) => {
|
||||
const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" }))
|
||||
const current = draft.commands.get(name) ?? (new Info({ name, template: "" }) as Types.DeepMutable<Info>)
|
||||
if (!draft.commands.has(name)) draft.commands.set(name, current)
|
||||
update(current)
|
||||
current.name = name
|
||||
|
|
@ -55,7 +52,7 @@ export const layer = Layer.effect(
|
|||
})
|
||||
|
||||
return Service.of({
|
||||
update: state.update,
|
||||
reload: state.reload,
|
||||
transform: state.transform,
|
||||
get: Effect.fn("CommandV2.get")(function* (name) {
|
||||
return state.get().commands.get(name)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as ConfigAgentPlugin from "./agent"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
|
|
@ -8,7 +9,6 @@ import { ConfigAgent } from "../agent"
|
|||
import { ConfigMarkdown } from "../markdown"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ConfigAgentV1 } from "../../v1/config/agent"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate"
|
||||
|
||||
|
|
@ -33,70 +33,70 @@ const agentKeys = new Set([
|
|||
"permissions",
|
||||
])
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-agent"),
|
||||
effect: Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
export const Plugin = define({
|
||||
id: "config-agent",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([entry])
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => content && decode(file, content)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
|
||||
yield* agent.update((editor) => {
|
||||
const global = documents.flatMap((document) => document.info.permissions ?? [])
|
||||
const configuredDefault = Config.latest(documents, "default_agent")
|
||||
if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault))
|
||||
for (const current of editor.list()) {
|
||||
editor.update(current.id, (agent) => agent.permissions.push(...global))
|
||||
}
|
||||
|
||||
for (const document of documents) {
|
||||
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
|
||||
const agentID = AgentV2.ID.make(id)
|
||||
if (item.disabled) {
|
||||
editor.remove(agentID)
|
||||
continue
|
||||
}
|
||||
|
||||
const exists = editor.get(agentID) !== undefined
|
||||
editor.update(agentID, (agent) => {
|
||||
if (!exists) agent.permissions.push(...global)
|
||||
if (item.model !== undefined) {
|
||||
const model = ModelV2.parse(item.model)
|
||||
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
|
||||
}
|
||||
if (item.variant !== undefined && agent.model !== undefined) {
|
||||
agent.model.variant = ModelV2.VariantID.make(item.variant)
|
||||
}
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(agent.request.headers, item.request.headers ?? {})
|
||||
Object.assign(agent.request.body, item.request.body ?? {})
|
||||
}
|
||||
if (item.system !== undefined) agent.system = item.system
|
||||
if (item.description !== undefined) agent.description = item.description
|
||||
if (item.mode !== undefined) agent.mode = item.mode
|
||||
if (item.hidden !== undefined) agent.hidden = item.hidden
|
||||
if (item.color !== undefined) agent.color = item.color
|
||||
if (item.steps !== undefined) agent.steps = item.steps
|
||||
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
|
||||
yield* ctx.agent.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([entry])
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => content && decode(file, content)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
const global = documents.flatMap((document) => document.info.permissions ?? [])
|
||||
const configuredDefault = Config.latest(documents, "default_agent")
|
||||
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
|
||||
for (const current of draft.list()) {
|
||||
draft.update(current.id, (agent) => agent.permissions.push(...global))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for (const document of documents) {
|
||||
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
|
||||
const agentID = AgentV2.ID.make(id)
|
||||
if (item.disabled) {
|
||||
draft.remove(agentID)
|
||||
continue
|
||||
}
|
||||
|
||||
const exists = draft.get(agentID) !== undefined
|
||||
draft.update(agentID, (agent) => {
|
||||
if (!exists) agent.permissions.push(...global)
|
||||
if (item.model !== undefined) {
|
||||
const model = ModelV2.parse(item.model)
|
||||
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
|
||||
}
|
||||
if (item.variant !== undefined && agent.model !== undefined) {
|
||||
agent.model.variant = ModelV2.VariantID.make(item.variant)
|
||||
}
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(agent.request.headers, item.request.headers ?? {})
|
||||
Object.assign(agent.request.body, item.request.body ?? {})
|
||||
}
|
||||
if (item.system !== undefined) agent.system = item.system
|
||||
if (item.description !== undefined) agent.description = item.description
|
||||
if (item.mode !== undefined) agent.mode = item.mode
|
||||
if (item.hidden !== undefined) agent.hidden = item.hidden
|
||||
if (item.color !== undefined) agent.color = item.color
|
||||
if (item.steps !== undefined) agent.steps = item.steps
|
||||
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,52 +1,51 @@
|
|||
export * as ConfigCommandPlugin from "./command"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { CommandV2 } from "../../command"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ConfigCommand } from "../command"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
|
||||
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-command"),
|
||||
effect: Effect.gen(function* () {
|
||||
const command = yield* CommandV2.Service
|
||||
export const Plugin = define({
|
||||
id: "config-command",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const transform = yield* command.transform()
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||
return loadDirectory(fs, entry.path).pipe(
|
||||
Effect.map((commands) => [
|
||||
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
||||
]),
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
|
||||
yield* transform((editor) => {
|
||||
for (const document of documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
editor.update(name, (item) => {
|
||||
item.template = command.template
|
||||
if (command.description !== undefined) item.description = command.description
|
||||
if (command.agent !== undefined) item.agent = command.agent
|
||||
if (command.model !== undefined) {
|
||||
const model = ModelV2.parse(command.model)
|
||||
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
|
||||
}
|
||||
if (command.variant !== undefined && item.model !== undefined) {
|
||||
item.model.variant = ModelV2.VariantID.make(command.variant)
|
||||
}
|
||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
||||
})
|
||||
yield* ctx.command.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||
return loadDirectory(fs, entry.path).pipe(
|
||||
Effect.map((commands) => [
|
||||
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
||||
]),
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
for (const document of documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
draft.update(name, (item) => {
|
||||
item.template = command.template
|
||||
if (command.description !== undefined) item.description = command.description
|
||||
if (command.agent !== undefined) item.agent = command.agent
|
||||
if (command.model !== undefined) {
|
||||
const model = ModelV2.parse(command.model)
|
||||
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
|
||||
}
|
||||
if (command.variant !== undefined && item.model !== undefined) {
|
||||
item.model.variant = ModelV2.VariantID.make(command.variant)
|
||||
}
|
||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
|
|||
91
packages/core/src/config/plugin/external.ts
Normal file
91
packages/core/src/config/plugin/external.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
export * as ConfigExternalPlugin from "./external"
|
||||
|
||||
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
import { Location } from "../../location"
|
||||
import { Npm } from "../../npm"
|
||||
import { define } from "../../plugin/internal"
|
||||
import { PluginPromise } from "../../plugin/promise"
|
||||
|
||||
const PluginModule = Schema.Struct({
|
||||
default: Schema.Union([
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
effect: Schema.declare<EffectPlugin["effect"]>(
|
||||
(input): input is EffectPlugin["effect"] => typeof input === "function",
|
||||
),
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
setup: Schema.declare<PromisePlugin["setup"]>(
|
||||
(input): input is PromisePlugin["setup"] => typeof input === "function",
|
||||
),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
export const Plugin = define({
|
||||
id: "config-plugin",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const configured: { package: string; options?: Record<string, any> }[] = []
|
||||
|
||||
for (const entry of yield* config.entries()) {
|
||||
if (entry.type === "document") {
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
for (const item of entry.info.plugins ?? []) {
|
||||
const ref = typeof item === "string" ? { package: item } : item
|
||||
const packageName = (() => {
|
||||
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
|
||||
if (ref.package.startsWith("./") || ref.package.startsWith("../")) {
|
||||
return path.resolve(directory, ref.package)
|
||||
}
|
||||
return ref.package
|
||||
})()
|
||||
configured.push({ package: packageName, options: ref.options })
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === "directory") {
|
||||
const files = yield* fs
|
||||
.glob("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: entry.path,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
files.sort()
|
||||
for (const file of files) configured.push({ package: file })
|
||||
}
|
||||
}
|
||||
|
||||
for (const ref of configured) {
|
||||
yield* Effect.gen(function* () {
|
||||
const entrypoint = path.isAbsolute(ref.package)
|
||||
? pathToFileURL(ref.package).href
|
||||
: (yield* npm.add(ref.package)).entrypoint
|
||||
if (!entrypoint) return
|
||||
|
||||
const mod = yield* Effect.promise(() => import(entrypoint))
|
||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
yield* ctx.plugin.add({
|
||||
id: plugin.id,
|
||||
effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }),
|
||||
})
|
||||
}).pipe(Effect.ignoreCause)
|
||||
}
|
||||
}).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
}),
|
||||
})
|
||||
|
|
@ -1,123 +1,124 @@
|
|||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Config } from "../../config"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ModelRequest } from "../../model-request"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-provider"),
|
||||
effect: Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
export const Plugin = define({
|
||||
id: "config-provider",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const transform = yield* catalog.transform()
|
||||
const integrationTransform = yield* integrations.transform()
|
||||
const entries = yield* config.entries()
|
||||
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
|
||||
),
|
||||
)
|
||||
yield* integrationTransform((integrations) => {
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const integrationID = Integration.ID.make(id)
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = item.name ?? integration.name
|
||||
})
|
||||
if (item.env !== undefined) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
yield* ctx.integration.transform(
|
||||
Effect.fn(function* (integrations) {
|
||||
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
|
||||
provider.env === undefined ? [] : [id],
|
||||
),
|
||||
),
|
||||
)
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const integrationID = id
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = item.name ?? integration.name
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
yield* transform((catalog) => {
|
||||
const configuredDefault = Config.latest(entries, "model")
|
||||
if (configuredDefault !== undefined) {
|
||||
const model = ModelV2.parse(configuredDefault)
|
||||
catalog.model.default.set(model.providerID, model.modelID)
|
||||
}
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const providerID = ProviderV2.ID.make(id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
Object.assign(provider.request.body, item.request.body)
|
||||
if (item.env !== undefined) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
})
|
||||
const providerApi = catalog.provider.get(providerID)?.provider.api
|
||||
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
|
||||
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, ModelV2.ID.make(id), (model) => {
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
||||
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
}
|
||||
}
|
||||
if (config.request !== undefined) {
|
||||
ModelRequest.assign(model.request, {
|
||||
headers: config.request.headers,
|
||||
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
|
||||
})
|
||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
for (const variant of config.variants) {
|
||||
let existing = model.variants.find((item) => item.id === variant.id)
|
||||
if (!existing) {
|
||||
existing = {
|
||||
id: variant.id,
|
||||
headers: {},
|
||||
body: {},
|
||||
generation: {},
|
||||
options: {},
|
||||
}
|
||||
model.variants.push(existing)
|
||||
}
|
||||
ModelRequest.assign(existing, {
|
||||
headers: variant.headers,
|
||||
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||
tier: cost.tier && { ...cost.tier },
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: {
|
||||
read: cost.cache?.read ?? 0,
|
||||
write: cost.cache?.write ?? 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (config.disabled !== undefined) model.enabled = !config.disabled
|
||||
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (catalog) {
|
||||
const entries = yield* config.entries()
|
||||
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredDefault = Config.latest(entries, "model")
|
||||
if (configuredDefault !== undefined) {
|
||||
const model = ModelV2.parse(configuredDefault)
|
||||
catalog.model.default.set(model.providerID, model.modelID)
|
||||
}
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const providerID = id
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
Object.assign(provider.request.body, item.request.body)
|
||||
}
|
||||
})
|
||||
const providerApi = catalog.provider.get(providerID)?.provider.api
|
||||
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
|
||||
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, id, (model) => {
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
||||
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
}
|
||||
}
|
||||
if (config.request !== undefined) {
|
||||
ModelRequest.assign(model.request, {
|
||||
headers: config.request.headers,
|
||||
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
|
||||
})
|
||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
for (const variant of config.variants) {
|
||||
let existing = model.variants.find((item) => item.id === variant.id)
|
||||
if (!existing) {
|
||||
existing = {
|
||||
id: variant.id,
|
||||
headers: {},
|
||||
body: {},
|
||||
generation: {},
|
||||
options: {},
|
||||
}
|
||||
model.variants.push(existing)
|
||||
}
|
||||
ModelRequest.assign(existing, {
|
||||
headers: variant.headers,
|
||||
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||
tier: cost.tier && { ...cost.tier },
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: {
|
||||
read: cost.cache?.read ?? 0,
|
||||
write: cost.cache?.write ?? 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (config.disabled !== undefined) model.enabled = !config.disabled
|
||||
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,57 +1,56 @@
|
|||
export * as ConfigReferencePlugin from "./reference"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigReference } from "../reference"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { Reference } from "../../reference"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
|
||||
export const Plugin = {
|
||||
id: PluginV2.ID.make("core/config-reference"),
|
||||
effect: Effect.gen(function* () {
|
||||
export const Plugin = define({
|
||||
id: "core/config-reference",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const references = yield* Reference.Service
|
||||
const update = yield* references.transform()
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of (yield* config.entries()).filter(
|
||||
(entry): entry is Config.Document => entry.type === "document",
|
||||
)) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? new Reference.LocalSource({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(
|
||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
||||
),
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
})
|
||||
: new Reference.GitSource({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
branch: typeof entry === "string" ? undefined : entry.branch,
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
yield* update((editor) => {
|
||||
for (const [name, source] of entries) editor.add(name, source)
|
||||
})
|
||||
const global = yield* Global.Service
|
||||
yield* ctx.reference.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of (yield* config.entries()).filter(
|
||||
(entry): entry is Config.Document => entry.type === "document",
|
||||
)) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? new Reference.LocalSource({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(
|
||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
||||
),
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
})
|
||||
: new Reference.GitSource({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
branch: typeof entry === "string" ? undefined : entry.branch,
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const [name, source] of entries) draft.add(name, source)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
function validAlias(name: string) {
|
||||
return name.length > 0 && !/[\/\s`,]/.test(name)
|
||||
|
|
|
|||
|
|
@ -1,48 +1,47 @@
|
|||
export * as ConfigSkillPlugin from "./skill"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { SkillV2 } from "../../skill"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-skill"),
|
||||
effect: Effect.gen(function* () {
|
||||
export const Plugin = define({
|
||||
id: "config-skill",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const transform = yield* skill.transform()
|
||||
const entries = yield* config.entries()
|
||||
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
|
||||
yield* transform((editor) => {
|
||||
for (const directory of directories) {
|
||||
editor.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
editor.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||
)
|
||||
}
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
editor.source(new SkillV2.UrlSource({ type: "url", url: item }))
|
||||
continue
|
||||
yield* ctx.skill.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const entries = yield* config.entries()
|
||||
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
for (const directory of directories) {
|
||||
draft.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
draft.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||
)
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
editor.source(
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
draft.source(new SkillV2.UrlSource({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
draft.source(
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSource
|
|||
{
|
||||
directory: AbsolutePath,
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
},
|
||||
) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,33 +29,33 @@ export class Key extends Schema.Class<Key>("Credential.Key")({
|
|||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([OAuth, Key])
|
||||
export const Value = Schema.Union([OAuth, Key])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Credential.Info" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
.annotate({ identifier: "Credential.Value" })
|
||||
export type Value = Schema.Schema.Type<typeof Value>
|
||||
|
||||
export class Stored extends Schema.Class<Stored>("Credential.Stored")({
|
||||
export class Info extends Schema.Class<Info>("Credential.Info")({
|
||||
id: ID,
|
||||
integrationID: IntegrationSchema.ID,
|
||||
label: Schema.String,
|
||||
value: Info,
|
||||
value: Value,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
/** Returns every stored credential. */
|
||||
readonly all: () => Effect.Effect<Stored[]>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
/** Returns stored credentials belonging to one integration. */
|
||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
|
||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]>
|
||||
/** Returns one stored credential by ID. */
|
||||
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Replaces any credential for an integration and returns the new record. */
|
||||
readonly create: (input: {
|
||||
readonly integrationID: IntegrationSchema.ID
|
||||
readonly value: Info
|
||||
readonly value: Value
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<Stored>
|
||||
}) => Effect.Effect<Info>
|
||||
/** Updates the label or secret value of a stored credential. */
|
||||
readonly update: (id: ID, updates: Partial<Pick<Stored, "label" | "value">>) => Effect.Effect<void>
|
||||
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
|
||||
/** Removes a stored credential. */
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
}
|
||||
|
|
@ -66,10 +66,10 @@ export const layer = Layer.effect(
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const decode = Schema.decodeUnknownSync(Value)
|
||||
const stored = (row: typeof CredentialTable.$inferSelect) => {
|
||||
if (!row.integration_id) return
|
||||
return new Stored({
|
||||
return new Info({
|
||||
id: row.id,
|
||||
integrationID: row.integration_id,
|
||||
label: row.label,
|
||||
|
|
@ -106,7 +106,7 @@ export const layer = Layer.effect(
|
|||
return row ? stored(row) : undefined
|
||||
}),
|
||||
create: Effect.fn("Credential.create")(function* (input) {
|
||||
const credential = new Stored({
|
||||
const credential = new Info({
|
||||
id: ID.create(),
|
||||
integrationID: input.integrationID,
|
||||
label: input.label ?? "default",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", {
|
|||
id: text().$type<Credential.ID>().primaryKey(),
|
||||
integration_id: text().$type<IntegrationSchema.ID>(),
|
||||
label: text().notNull(),
|
||||
value: text({ mode: "json" }).$type<Credential.Info>().notNull(),
|
||||
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
|
||||
connector_id: text(),
|
||||
method_id: text(),
|
||||
active: integer({ mode: "boolean" }),
|
||||
|
|
|
|||
3
packages/core/src/database/migration.gen.ts
generated
3
packages/core/src/database/migration.gen.ts
generated
|
|
@ -37,5 +37,8 @@ export const migrations = (
|
|||
import("./migration/20260611035744_credential"),
|
||||
import("./migration/20260611192811_lush_chimera"),
|
||||
import("./migration/20260612174303_project_dir_strategy"),
|
||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||
import("./migration/20260622170816_reset_v2_session_state"),
|
||||
import("./migration/20260622202450_simplify_session_input"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260622142730_simplify_session_context_epoch",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`agent\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`replacement_seq\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260622170816_reset_v2_session_state",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
|
||||
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\`;`)
|
||||
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
|
||||
yield* tx.run(`DELETE FROM \`workspace\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260622202450_simplify_session_input",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
|
||||
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\`;`)
|
||||
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
|
||||
yield* tx.run(`DELETE FROM \`workspace\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -149,11 +149,8 @@ export default {
|
|||
CREATE TABLE \`session_context_epoch\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`baseline\` text NOT NULL,
|
||||
\`agent\` text DEFAULT 'build' NOT NULL,
|
||||
\`snapshot\` text NOT NULL,
|
||||
\`baseline_seq\` integer NOT NULL,
|
||||
\`replacement_seq\` integer,
|
||||
\`revision\` integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { and, asc, eq, gt } from "drizzle-orm"
|
|||
import { Database } from "./database/database"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||
import { Location } from "./location"
|
||||
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
|
||||
import { externalID, type ExternalID, withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
|
|
@ -19,16 +19,9 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
|
|||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
/**
|
||||
* Durable aggregate continuation position for embedded replay streams.
|
||||
* TODO: Decide whether a future HTTP / SDK surface should expose an opaque cursor instead.
|
||||
*/
|
||||
export const Cursor = NonNegativeInt.pipe(Schema.brand("EventV2.Cursor"))
|
||||
export type Cursor = typeof Cursor.Type
|
||||
|
||||
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
|
||||
readonly type: Type
|
||||
readonly sync?: {
|
||||
readonly durable?: {
|
||||
readonly version: number
|
||||
readonly aggregate: string
|
||||
}
|
||||
|
|
@ -41,22 +34,31 @@ export type Payload<D extends Definition = Definition> = {
|
|||
readonly id: ID
|
||||
readonly type: D["type"]
|
||||
readonly data: Data<D>
|
||||
/** Durable aggregate order, populated while synchronized events are projected. */
|
||||
readonly seq?: number
|
||||
readonly version?: number
|
||||
readonly durable?: {
|
||||
readonly aggregateID: string
|
||||
readonly seq: number
|
||||
readonly version: number
|
||||
}
|
||||
readonly location?: Location.Ref
|
||||
readonly metadata?: Record<string, unknown>
|
||||
/** Internal replay marker for projectors that own non-replicated operational state. */
|
||||
readonly replay?: boolean
|
||||
}
|
||||
|
||||
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
||||
type AnyProjector = (event: Payload) => Effect.Effect<void>
|
||||
export type CommitGuard = (event: Payload) => Effect.Effect<void>
|
||||
export type Listener = (event: Payload) => Effect.Effect<void>
|
||||
export type Sync = (event: Payload) => Effect.Effect<void>
|
||||
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
||||
export type Unsubscribe = Effect.Effect<void>
|
||||
|
||||
export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
|
||||
db: Database.Interface["db"],
|
||||
aggregateID: string,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row?.seq ?? -1
|
||||
})
|
||||
|
||||
export type SerializedEvent = {
|
||||
readonly id: ID
|
||||
readonly type: string
|
||||
|
|
@ -65,13 +67,8 @@ export type SerializedEvent = {
|
|||
readonly data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CursorEvent<E extends Payload = Payload> = {
|
||||
readonly cursor: Cursor
|
||||
readonly event: E
|
||||
}
|
||||
|
||||
export class InvalidSyncEventError extends Schema.TaggedErrorClass<InvalidSyncEventError>()(
|
||||
"EventV2.InvalidSyncEvent",
|
||||
export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDurableEventError>()(
|
||||
"EventV2.InvalidDurableEvent",
|
||||
{
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
|
|
@ -83,19 +80,11 @@ export function versionedType(type: string, version: number) {
|
|||
}
|
||||
|
||||
export const registry = new Map<string, Definition>()
|
||||
type SyncDefinition = Definition & {
|
||||
readonly sync: NonNullable<Definition["sync"]>
|
||||
readonly encode: (data: unknown) => unknown
|
||||
readonly decode: (data: unknown) => unknown
|
||||
}
|
||||
const syncRegistry = new Map<string, SyncDefinition>()
|
||||
|
||||
// Synchronized events cross a JSON boundary, so their data schemas must encode and decode without services.
|
||||
const syncCodec = (definition: Definition) => definition.data as Schema.Codec<unknown, unknown, never, never>
|
||||
const durableRegistry = new Map<string, Definition>()
|
||||
|
||||
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
|
||||
readonly type: Type
|
||||
readonly sync?: {
|
||||
readonly durable?: {
|
||||
readonly version: number
|
||||
readonly aggregate: string
|
||||
}
|
||||
|
|
@ -106,28 +95,25 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
|
|||
id: ID,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
type: Schema.Literal(input.type),
|
||||
version: Schema.optional(Schema.Number),
|
||||
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
|
||||
location: Schema.optional(Location.Ref),
|
||||
data: Data,
|
||||
}).annotate({ identifier: input.type })
|
||||
|
||||
const definition = Object.assign(Payload, {
|
||||
type: input.type,
|
||||
...(input.sync === undefined ? {} : { sync: input.sync }),
|
||||
...(input.durable === undefined ? {} : { durable: input.durable }),
|
||||
data: Data,
|
||||
})
|
||||
const existing = registry.get(input.type)
|
||||
if (input.sync === undefined || existing?.sync === undefined || input.sync.version >= existing.sync.version) {
|
||||
if (
|
||||
input.durable === undefined ||
|
||||
existing?.durable === undefined ||
|
||||
input.durable.version >= existing.durable.version
|
||||
) {
|
||||
registry.set(input.type, definition)
|
||||
}
|
||||
if (input.sync)
|
||||
syncRegistry.set(
|
||||
versionedType(input.type, input.sync.version),
|
||||
Object.assign(definition, {
|
||||
encode: Schema.encodeUnknownSync(syncCodec(definition)),
|
||||
decode: Schema.decodeUnknownSync(syncCodec(definition)),
|
||||
}) as SyncDefinition,
|
||||
)
|
||||
if (input.durable) durableRegistry.set(versionedType(input.type, input.durable.version), definition)
|
||||
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
|
||||
Definition<Type, Schema.Struct<Fields>>
|
||||
}
|
||||
|
|
@ -140,7 +126,7 @@ export interface PublishOptions {
|
|||
readonly id?: ID
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly location?: Location.Ref
|
||||
/** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */
|
||||
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
|
||||
readonly commit?: (seq: number) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
|
|
@ -152,14 +138,10 @@ export interface Interface {
|
|||
) => Effect.Effect<Payload<D>>
|
||||
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
||||
readonly all: () => Stream.Stream<Payload>
|
||||
readonly aggregateEvents: (input: {
|
||||
readonly aggregateID: string
|
||||
readonly after?: Cursor
|
||||
}) => Stream.Stream<CursorEvent>
|
||||
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
|
||||
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
|
||||
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
|
||||
readonly project: <D extends Definition>(definition: D, projector: Projector<D>) => Effect.Effect<void>
|
||||
readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload>
|
||||
/** @deprecated Use `all()` and consume the returned stream. */
|
||||
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
||||
readonly replay: (
|
||||
event: SerializedEvent,
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
|
|
@ -182,37 +164,37 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const all = yield* PubSub.unbounded<Payload>()
|
||||
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
const projectors = new Map<string, AnyProjector[]>()
|
||||
const commitGuards = new Array<CommitGuard>()
|
||||
const listeners = new Array<Listener>()
|
||||
const syncHandlers = new Array<Sync>()
|
||||
const pubsub = {
|
||||
all: yield* PubSub.unbounded<Payload>(),
|
||||
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
|
||||
typed: new Map<string, PubSub.PubSub<Payload>>(),
|
||||
}
|
||||
const projectors = new Map<string, Subscriber[]>()
|
||||
const listeners = new Array<Subscriber>()
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
const getOrCreate = (definition: Definition) =>
|
||||
Effect.gen(function* () {
|
||||
const existing = typed.get(definition.type)
|
||||
const existing = pubsub.typed.get(definition.type)
|
||||
if (existing) return existing
|
||||
const pubsub = yield* PubSub.unbounded<Payload>()
|
||||
typed.set(definition.type, pubsub)
|
||||
return pubsub
|
||||
const created = yield* PubSub.unbounded<Payload>()
|
||||
pubsub.typed.set(definition.type, created)
|
||||
return created
|
||||
})
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* PubSub.shutdown(all)
|
||||
yield* PubSub.shutdown(pubsub.all)
|
||||
yield* Effect.forEach(
|
||||
synchronized.values(),
|
||||
pubsub.durable.values(),
|
||||
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
|
||||
{ discard: true },
|
||||
)
|
||||
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
|
||||
yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
|
||||
}),
|
||||
)
|
||||
|
||||
function commitSyncEvent(
|
||||
function commitDurableEvent(
|
||||
event: Payload,
|
||||
input?: {
|
||||
readonly seq: number
|
||||
|
|
@ -224,28 +206,20 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
) {
|
||||
return Effect.gen(function* () {
|
||||
const definition = registry.get(event.type)
|
||||
const sync = definition?.sync
|
||||
if (sync) {
|
||||
if (event.version !== sync.version) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Expected event version ${sync.version}, got ${event.version}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
|
||||
const durable = definition?.durable
|
||||
if (durable) {
|
||||
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
|
||||
if (typeof aggregateID !== "string") {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Expected string aggregate field ${sync.aggregate}`,
|
||||
message: `Expected string aggregate field ${durable.aggregate}`,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
if (input && input.aggregateID !== aggregateID) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
|
||||
}),
|
||||
|
|
@ -265,12 +239,12 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const latest = row?.seq ?? -1
|
||||
const encoded = syncRegistry
|
||||
.get(versionedType(definition.type, sync.version))!
|
||||
.encode(event.data) as Record<string, unknown>
|
||||
const encoded = Schema.encodeUnknownSync(
|
||||
definition.data as Schema.Codec<unknown, unknown, never, never>,
|
||||
)(event.data) as Record<string, unknown>
|
||||
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
||||
}),
|
||||
|
|
@ -285,7 +259,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
.pipe(Effect.orDie)
|
||||
if (
|
||||
stored?.id === event.id &&
|
||||
stored.type === versionedType(definition.type, sync.version) &&
|
||||
stored.type === versionedType(definition.type, durable.version) &&
|
||||
isDeepStrictEqual(stored.data, encoded)
|
||||
) {
|
||||
if (input.ownerID && row?.ownerID == null) {
|
||||
|
|
@ -299,7 +273,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
return
|
||||
}
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
|
||||
}),
|
||||
|
|
@ -311,7 +285,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
const seq = input?.seq ?? latest + 1
|
||||
if (input && seq !== latest + 1) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
||||
}),
|
||||
|
|
@ -325,16 +299,17 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
.pipe(Effect.orDie)
|
||||
if (stored)
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
||||
}),
|
||||
)
|
||||
for (const guard of commitGuards) {
|
||||
yield* guard(event)
|
||||
}
|
||||
const committed = {
|
||||
...event,
|
||||
durable: { aggregateID, seq, version: durable.version },
|
||||
} as Payload
|
||||
for (const projector of list) {
|
||||
yield* projector({ ...event, seq } as Payload)
|
||||
yield* projector(committed)
|
||||
}
|
||||
if (commit) yield* commit(seq)
|
||||
yield* db
|
||||
|
|
@ -356,7 +331,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
id: event.id,
|
||||
aggregate_id: aggregateID,
|
||||
seq,
|
||||
type: versionedType(definition.type, sync.version),
|
||||
type: versionedType(definition.type, durable.version),
|
||||
data: encoded,
|
||||
},
|
||||
])
|
||||
|
|
@ -369,8 +344,8 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
.pipe(Effect.orDie)
|
||||
if (committed) {
|
||||
yield* Effect.forEach(
|
||||
synchronized.get(committed.aggregateID) ?? [],
|
||||
(pubsub) => PubSub.publish(pubsub, undefined),
|
||||
pubsub.durable.get(committed.aggregateID) ?? [],
|
||||
(wake) => PubSub.publish(wake, undefined),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
|
|
@ -384,19 +359,25 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
|
||||
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
|
||||
return Effect.gen(function* () {
|
||||
const durable = registry.get(event.type)?.sync !== undefined
|
||||
if (!durable && commit)
|
||||
const definition = registry.get(event.type)
|
||||
if (!definition?.durable && commit)
|
||||
return yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: "Local commit hooks require a synchronized event",
|
||||
message: "Local commit hooks require a durable event",
|
||||
}),
|
||||
)
|
||||
if (durable) {
|
||||
const committed = yield* commitSyncEvent(event as Payload, undefined, commit)
|
||||
if (definition?.durable) {
|
||||
const committed = yield* commitDurableEvent(event as Payload, undefined, commit)
|
||||
if (committed) {
|
||||
event = { ...event, seq: committed.seq }
|
||||
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
|
||||
event = {
|
||||
...event,
|
||||
durable: {
|
||||
aggregateID: committed.aggregateID,
|
||||
seq: committed.seq,
|
||||
version: definition.durable.version,
|
||||
},
|
||||
}
|
||||
yield* notify(event as Payload, true)
|
||||
return event
|
||||
}
|
||||
|
|
@ -406,12 +387,11 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
})
|
||||
}
|
||||
|
||||
const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect<void>) =>
|
||||
const observe = (event: Payload, observer: (event: Payload) => Effect.Effect<void>) =>
|
||||
Effect.suspend(() => observer(event)).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
(cause) =>
|
||||
Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }),
|
||||
(cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -419,12 +399,12 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
return Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
listeners,
|
||||
(listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)),
|
||||
(listener) => (isolateListeners ? observe(event, listener) : listener(event)),
|
||||
{ discard: true },
|
||||
)
|
||||
const pubsub = typed.get(event.type)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, event)
|
||||
yield* PubSub.publish(all, event)
|
||||
const typed = pubsub.typed.get(event.type)
|
||||
if (typed) yield* PubSub.publish(typed, event)
|
||||
yield* PubSub.publish(pubsub.all, event)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -441,7 +421,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
id: options?.id ?? ID.create(),
|
||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||
type: definition.type,
|
||||
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
|
||||
...(location ? { location } : {}),
|
||||
data,
|
||||
} as Payload<D>,
|
||||
|
|
@ -455,27 +434,37 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const definition = syncRegistry.get(event.type)
|
||||
if (!definition) {
|
||||
const definition = durableRegistry.get(event.type)
|
||||
if (!definition?.durable) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
|
||||
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
|
||||
)
|
||||
} else {
|
||||
const payload = {
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
version: definition.sync.version,
|
||||
data: definition.decode(event.data),
|
||||
replay: true,
|
||||
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(
|
||||
event.data,
|
||||
),
|
||||
} as Payload
|
||||
const committed = yield* commitSyncEvent(payload, {
|
||||
const committed = yield* commitDurableEvent(payload, {
|
||||
seq: event.seq,
|
||||
aggregateID: event.aggregateID,
|
||||
ownerID: options?.ownerID,
|
||||
strictOwner: options?.strictOwner,
|
||||
})
|
||||
if (committed && options?.publish) {
|
||||
yield* notify({ ...payload, seq: committed.seq }, true)
|
||||
yield* notify(
|
||||
{
|
||||
...payload,
|
||||
durable: {
|
||||
aggregateID: committed.aggregateID,
|
||||
seq: committed.seq,
|
||||
version: definition.durable.version,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -490,7 +479,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
if (!source) return undefined
|
||||
if (events.some((event) => event.aggregateID !== source)) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: events[0]?.type ?? "unknown",
|
||||
message: "Replay events must belong to the same aggregate",
|
||||
}),
|
||||
|
|
@ -501,7 +490,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
const seq = start + index
|
||||
if (event.seq !== seq) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
|
||||
}),
|
||||
|
|
@ -540,22 +529,18 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
Stream.map((event) => event as Payload<D>),
|
||||
)
|
||||
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
|
||||
|
||||
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => {
|
||||
const definition = syncRegistry.get(event.type)
|
||||
if (!definition) {
|
||||
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` })
|
||||
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
||||
const definition = durableRegistry.get(event.type)
|
||||
if (!definition?.durable) {
|
||||
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
||||
}
|
||||
return {
|
||||
cursor: Cursor.make(event.seq),
|
||||
event: {
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
version: definition.sync.version,
|
||||
seq: event.seq,
|
||||
data: definition.decode(event.data),
|
||||
},
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
||||
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -583,43 +568,40 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
),
|
||||
)
|
||||
|
||||
const subscribeSynchronized = (aggregateID: string) =>
|
||||
const subscribeDurable = (aggregateID: string) =>
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub.sliding<void>(1)
|
||||
const subscription = yield* PubSub.subscribe(pubsub)
|
||||
const wake = yield* PubSub.sliding<void>(1)
|
||||
const subscription = yield* PubSub.subscribe(wake)
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const pubsubs = synchronized.get(aggregateID) ?? new Set()
|
||||
pubsubs.add(pubsub)
|
||||
synchronized.set(aggregateID, pubsubs)
|
||||
const wakes = pubsub.durable.get(aggregateID) ?? new Set()
|
||||
wakes.add(wake)
|
||||
pubsub.durable.set(aggregateID, wakes)
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
const pubsubs = synchronized.get(aggregateID)
|
||||
pubsubs?.delete(pubsub)
|
||||
if (pubsubs?.size === 0) synchronized.delete(aggregateID)
|
||||
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))),
|
||||
const wakes = pubsub.durable.get(aggregateID)
|
||||
wakes?.delete(wake)
|
||||
if (wakes?.size === 0) pubsub.durable.delete(aggregateID)
|
||||
}).pipe(Effect.andThen(PubSub.shutdown(wake))),
|
||||
)
|
||||
return subscription
|
||||
})
|
||||
|
||||
const streamEvents = (input: {
|
||||
readonly aggregateID: string
|
||||
readonly after?: Cursor
|
||||
}): Stream.Stream<CursorEvent> =>
|
||||
const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const synchronized = yield* subscribeSynchronized(input.aggregateID)
|
||||
let cursor = input.after ?? -1
|
||||
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe(
|
||||
const wakes = yield* subscribeDurable(input.aggregateID)
|
||||
let sequence = input.after ?? -1
|
||||
const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
|
||||
Effect.tap((events) =>
|
||||
Effect.sync(() => {
|
||||
cursor = events.at(-1)?.cursor ?? cursor
|
||||
sequence = events.at(-1)?.durable?.seq ?? sequence
|
||||
}),
|
||||
),
|
||||
)
|
||||
const historical = yield* read
|
||||
const live = Stream.fromSubscription(synchronized).pipe(
|
||||
const live = Stream.fromSubscription(wakes).pipe(
|
||||
Stream.mapEffect(() => read),
|
||||
Stream.flattenIterable,
|
||||
)
|
||||
|
|
@ -627,7 +609,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
}),
|
||||
)
|
||||
|
||||
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
|
||||
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
|
||||
Effect.sync(() => {
|
||||
listeners.push(listener)
|
||||
return Effect.sync(() => {
|
||||
|
|
@ -636,21 +618,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
})
|
||||
})
|
||||
|
||||
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
|
||||
Effect.sync(() => {
|
||||
syncHandlers.push(handler)
|
||||
return Effect.sync(() => {
|
||||
const index = syncHandlers.indexOf(handler)
|
||||
if (index >= 0) syncHandlers.splice(index, 1)
|
||||
})
|
||||
})
|
||||
|
||||
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
commitGuards.push(guard)
|
||||
})
|
||||
|
||||
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
|
||||
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
const list = projectors.get(definition.type) ?? []
|
||||
list.push((event) => projector(event as Payload<D>))
|
||||
|
|
@ -661,10 +629,8 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
publish,
|
||||
subscribe,
|
||||
all: streamAll,
|
||||
aggregateEvents: streamEvents,
|
||||
sync,
|
||||
durable,
|
||||
listen,
|
||||
beforeCommit,
|
||||
project,
|
||||
replay,
|
||||
replayAll,
|
||||
|
|
|
|||
|
|
@ -123,6 +123,6 @@ const baseLayer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
export const locationLayer = layer
|
||||
|
|
|
|||
|
|
@ -232,6 +232,6 @@ export const fffLayer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
export const locationLayer = Layer.unwrap(
|
||||
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { filesystem } from "./effect/layer-node-platform"
|
|||
export namespace FSUtil {
|
||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||
method: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export type Error = PlatformError | FileSystemError
|
||||
|
|
|
|||
|
|
@ -32,14 +32,14 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
|
|||
message: Schema.String,
|
||||
directory: Schema.optional(AbsolutePath),
|
||||
forceRequired: Schema.optional(Schema.Boolean),
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
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),
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
export * as Integration from "./integration"
|
||||
|
||||
import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect"
|
||||
import { castDraft, enableMapSet, type Draft } from "immer"
|
||||
import {
|
||||
Cause,
|
||||
Clock,
|
||||
Context,
|
||||
Duration,
|
||||
Effect,
|
||||
Exit,
|
||||
Layer,
|
||||
Schedule,
|
||||
Schema,
|
||||
Scope,
|
||||
SynchronizedRef,
|
||||
Types,
|
||||
} from "effect"
|
||||
import { Credential } from "./credential"
|
||||
import { IntegrationSchema } from "./integration/schema"
|
||||
import { withStatics } from "./schema"
|
||||
|
|
@ -42,12 +54,14 @@ export const SelectPrompt = Schema.Struct({
|
|||
type: Schema.Literal("select"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
options: Schema.Array(
|
||||
Schema.Struct({
|
||||
label: Schema.String,
|
||||
value: Schema.String,
|
||||
hint: Schema.optional(Schema.String),
|
||||
}),
|
||||
options: Schema.mutable(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
label: Schema.String,
|
||||
value: Schema.String,
|
||||
hint: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
),
|
||||
when: Schema.optional(When),
|
||||
}).annotate({ identifier: "Integration.SelectPrompt" })
|
||||
|
|
@ -60,7 +74,7 @@ export const OAuthMethod = Schema.Struct({
|
|||
id: MethodID,
|
||||
type: Schema.Literal("oauth"),
|
||||
label: Schema.String,
|
||||
prompts: Schema.optional(Schema.Array(Prompt)),
|
||||
prompts: Schema.optional(Schema.mutable(Schema.Array(Prompt))),
|
||||
}).annotate({ identifier: "Integration.OAuthMethod" })
|
||||
export type OAuthMethod = typeof OAuthMethod.Type
|
||||
|
||||
|
|
@ -72,7 +86,7 @@ export type KeyMethod = typeof KeyMethod.Type
|
|||
|
||||
export const EnvMethod = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
names: Schema.Array(Schema.String),
|
||||
names: Schema.mutable(Schema.Array(Schema.String)),
|
||||
}).annotate({ identifier: "Integration.EnvMethod" })
|
||||
export type EnvMethod = typeof EnvMethod.Type
|
||||
|
||||
|
|
@ -82,8 +96,8 @@ export type Method = typeof Method.Type
|
|||
export class Info extends Schema.Class<Info>("Integration.Info")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
methods: Schema.Array(Method),
|
||||
connections: Schema.Array(IntegrationConnection.Info),
|
||||
methods: Schema.mutable(Schema.Array(Method)),
|
||||
connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
|
||||
}) {}
|
||||
|
||||
export type Inputs = Readonly<{ [key: string]: string }>
|
||||
|
|
@ -94,11 +108,11 @@ export type OAuthAuthorization = {
|
|||
} & (
|
||||
| {
|
||||
readonly mode: "auto"
|
||||
readonly callback: Effect.Effect<Credential.Info, unknown>
|
||||
readonly callback: Effect.Effect<Credential.Value, unknown>
|
||||
}
|
||||
| {
|
||||
readonly mode: "code"
|
||||
readonly callback: (code: string) => Effect.Effect<Credential.Info, unknown>
|
||||
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -154,7 +168,7 @@ export class CodeRequiredError extends Schema.TaggedErrorClass<CodeRequiredError
|
|||
}) {}
|
||||
|
||||
export class AuthorizationError extends Schema.TaggedErrorClass<AuthorizationError>()("Integration.Authorization", {
|
||||
cause: Schema.Defect,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type Error = CodeRequiredError | AuthorizationError
|
||||
|
|
@ -172,19 +186,19 @@ export type Ref = {
|
|||
}
|
||||
|
||||
type Entry = {
|
||||
ref: Ref
|
||||
methods: Method[]
|
||||
implementations: Map<MethodID, OAuthImplementation>
|
||||
ref: Types.DeepMutable<Ref>
|
||||
methods: Types.DeepMutable<Method>[]
|
||||
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
integrations: Map<ID, Entry>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
list: () => readonly Ref[]
|
||||
get: (id: ID) => Ref | undefined
|
||||
update: (id: ID, update: (integration: Draft<Ref>) => void) => void
|
||||
update: (id: ID, update: (integration: Types.DeepMutable<Ref>) => void) => void
|
||||
remove: (id: ID) => void
|
||||
method: {
|
||||
list: (integrationID: ID) => readonly Method[]
|
||||
|
|
@ -193,18 +207,13 @@ export type Editor = {
|
|||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
/** Registers a scoped transform over the integration registry. */
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
/** Registers and immediately applies a scoped integration registry update. */
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
/** Returns one integration with its methods and current connections. */
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Returns all integrations with their methods and current connections. */
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly connection: {
|
||||
/** Returns active connections for every registered or credential-backed integration. */
|
||||
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
|
||||
/** Returns the active connection for one integration. */
|
||||
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
||||
/** Runs a key method and stores the resulting credential. */
|
||||
|
|
@ -230,7 +239,7 @@ export interface Interface {
|
|||
/** Updates a stored credential exposed as a connection. */
|
||||
readonly update: (
|
||||
credentialID: Credential.ID,
|
||||
updates: Partial<Pick<Credential.Stored, "label">>,
|
||||
updates: Partial<Pick<Credential.Info, "label">>,
|
||||
) => Effect.Effect<void>
|
||||
/** Removes a stored credential connection. */
|
||||
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||
|
|
@ -252,8 +261,6 @@ export interface Interface {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
|
||||
|
||||
enableMapSet()
|
||||
|
||||
const attemptLifetime = Duration.toMillis(Duration.minutes(10))
|
||||
const terminalRetention = Duration.toMillis(Duration.minutes(1))
|
||||
const scrubInterval = Duration.seconds(30)
|
||||
|
|
@ -284,15 +291,17 @@ export const locationLayer = Layer.effect(
|
|||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ integrations: new Map<ID, Entry>() }),
|
||||
editor: (draft) => ({
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[],
|
||||
get: (id) => draft.integrations.get(id)?.ref as Ref | undefined,
|
||||
update: (id, update) => {
|
||||
const current =
|
||||
draft.integrations.get(id) ??
|
||||
castDraft({ ref: { id, name: id } as Ref, methods: [], implementations: new Map() })
|
||||
const current = draft.integrations.get(id) ?? {
|
||||
ref: { id, name: id },
|
||||
methods: [],
|
||||
implementations: new Map(),
|
||||
}
|
||||
if (!draft.integrations.has(id)) draft.integrations.set(id, current)
|
||||
update(current.ref)
|
||||
current.ref.id = id
|
||||
|
|
@ -301,16 +310,14 @@ export const locationLayer = Layer.effect(
|
|||
method: {
|
||||
list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
|
||||
update: (implementation) => {
|
||||
const current =
|
||||
draft.integrations.get(implementation.integrationID) ??
|
||||
castDraft({
|
||||
ref: {
|
||||
id: implementation.integrationID,
|
||||
name: implementation.integrationID,
|
||||
} as Ref,
|
||||
methods: [],
|
||||
implementations: new Map<MethodID, OAuthImplementation>(),
|
||||
})
|
||||
const current = draft.integrations.get(implementation.integrationID) ?? {
|
||||
ref: {
|
||||
id: implementation.integrationID,
|
||||
name: implementation.integrationID,
|
||||
},
|
||||
methods: [],
|
||||
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
|
||||
}
|
||||
if (!draft.integrations.has(implementation.integrationID)) {
|
||||
draft.integrations.set(implementation.integrationID, current)
|
||||
}
|
||||
|
|
@ -319,10 +326,13 @@ export const locationLayer = Layer.effect(
|
|||
if (method.type !== "oauth" || implementation.method.type !== "oauth") return true
|
||||
return method.id === implementation.method.id
|
||||
})
|
||||
if (index === -1) current.methods.push(castDraft(implementation.method))
|
||||
else current.methods[index] = castDraft(implementation.method)
|
||||
if (isOAuthImplementation(implementation)) {
|
||||
current.implementations.set(implementation.method.id, castDraft(implementation))
|
||||
if (index === -1) current.methods.push(implementation.method as Types.DeepMutable<Method>)
|
||||
else current.methods[index] = implementation.method as Types.DeepMutable<Method>
|
||||
if (implementation.method.type === "oauth") {
|
||||
current.implementations.set(
|
||||
implementation.method.id,
|
||||
implementation as Types.DeepMutable<OAuthImplementation>,
|
||||
)
|
||||
}
|
||||
},
|
||||
remove: (integrationID, method) => {
|
||||
|
|
@ -341,39 +351,27 @@ export const locationLayer = Layer.effect(
|
|||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
|
||||
const connected = saved.map((credential) => ({
|
||||
type: "credential" as const,
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
}))
|
||||
const detected = entry.methods
|
||||
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
|
||||
const credentials = saved
|
||||
.map((credential) => ({
|
||||
type: "credential" as const,
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
}))
|
||||
.toReversed()
|
||||
const env = (entry?.methods ?? [])
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names.filter((name) => process.env[name]))
|
||||
.map((name) => ({ type: "env" as const, name }))
|
||||
return [...connected, ...detected]
|
||||
return [...credentials, ...env]
|
||||
}
|
||||
|
||||
const activeConnection = (
|
||||
entry: Entry | undefined,
|
||||
saved: readonly Credential.Stored[],
|
||||
): IntegrationConnection.Info | undefined => {
|
||||
const credential = saved.at(-1)
|
||||
if (credential) return { type: "credential", id: credential.id, label: credential.label }
|
||||
if (!entry) return
|
||||
const name = entry.methods
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names)
|
||||
.find((name) => process.env[name])
|
||||
if (name) return { type: "env", name }
|
||||
}
|
||||
|
||||
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
|
||||
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
|
||||
new Info({
|
||||
id: entry.ref.id,
|
||||
name: entry.ref.name,
|
||||
methods: entry.methods,
|
||||
connections: connections(entry, saved),
|
||||
connections,
|
||||
})
|
||||
|
||||
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
|
|
@ -387,7 +385,7 @@ export const locationLayer = Layer.effect(
|
|||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Info, unknown>) {
|
||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||
const attempt = current.get(attemptID)
|
||||
|
|
@ -434,32 +432,22 @@ export const locationLayer = Layer.effect(
|
|||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
update: state.update,
|
||||
reload: state.reload,
|
||||
get: Effect.fn("Integration.get")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
if (!entry) return undefined
|
||||
return project(entry, yield* credentials.list(id))
|
||||
return project(entry, resolveConnections(entry, yield* credentials.list(id)))
|
||||
}),
|
||||
list: Effect.fn("Integration.list")(function* () {
|
||||
return (yield* Effect.forEach(state.get().integrations.values(), (entry) =>
|
||||
Effect.gen(function* () {
|
||||
return project(entry, yield* credentials.list(entry.ref.id))
|
||||
}),
|
||||
)).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||
return Array.from(state.get().integrations.values(), (entry) =>
|
||||
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
|
||||
).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
connection: {
|
||||
list: Effect.fn("Integration.connection.list")(function* () {
|
||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||
return new Map(
|
||||
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
|
||||
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
|
||||
return connection ? [[id, connection] as const] : []
|
||||
}),
|
||||
)
|
||||
}),
|
||||
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
return activeConnection(entry, yield* credentials.list(id))
|
||||
return resolveConnections(entry, yield* credentials.list(id))[0]
|
||||
}),
|
||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||
const method = state
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Catalog } from "./catalog"
|
|||
import { Integration } from "./integration"
|
||||
import { CommandV2 } from "./command"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { PluginBoot } from "./plugin/boot"
|
||||
import { PluginInternal } from "./plugin/internal"
|
||||
import { Project } from "./project"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { ProjectDirectories } from "./project/directories"
|
||||
|
|
@ -65,7 +65,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||
Integration.locationLayer,
|
||||
CommandV2.locationLayer,
|
||||
AgentV2.locationLayer,
|
||||
PluginBoot.locationLayer,
|
||||
PluginInternal.locationLayer,
|
||||
ProjectCopy.locationLayer,
|
||||
FileSystem.locationLayer,
|
||||
Watcher.locationLayer,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export type Request = typeof Request.Type
|
|||
interface MutableRequest {
|
||||
headers: Record<string, string>
|
||||
body: Record<string, unknown>
|
||||
generation?: Generation
|
||||
generation?: Record<string, unknown>
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { DateTime, Schema } from "effect"
|
||||
import { DateTimeUtcFromMillis } from "effect/Schema"
|
||||
import { Schema, Types } from "effect"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { ModelRequest } from "./model-request"
|
||||
|
||||
|
|
@ -16,8 +15,8 @@ export type Family = typeof Family.Type
|
|||
export const Capabilities = Schema.Struct({
|
||||
tools: Schema.Boolean,
|
||||
// mime patterns, image, audio, video/*, text/*
|
||||
input: Schema.String.pipe(Schema.Array),
|
||||
output: Schema.String.pipe(Schema.Array),
|
||||
input: Schema.String.pipe(Schema.Array, Schema.mutable),
|
||||
output: Schema.String.pipe(Schema.Array, Schema.mutable),
|
||||
})
|
||||
export type Capabilities = typeof Capabilities.Type
|
||||
|
||||
|
|
@ -67,11 +66,11 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
|||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
...ModelRequest.Request.fields,
|
||||
}).pipe(Schema.Array),
|
||||
}).pipe(Schema.Array, Schema.mutable),
|
||||
time: Schema.Struct({
|
||||
released: DateTimeUtcFromMillis,
|
||||
released: Schema.Finite,
|
||||
}),
|
||||
cost: Cost.pipe(Schema.Array),
|
||||
cost: Cost.pipe(Schema.Array, Schema.mutable),
|
||||
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
|
||||
enabled: Schema.Boolean,
|
||||
limit: Schema.Struct({
|
||||
|
|
@ -103,7 +102,7 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
|||
},
|
||||
variants: [],
|
||||
time: {
|
||||
released: DateTime.makeUnsafe(0),
|
||||
released: 0,
|
||||
},
|
||||
cost: [],
|
||||
status: "active",
|
||||
|
|
@ -116,6 +115,10 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
|||
}
|
||||
}
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
|
||||
api: ProviderV2.MutableApi<Api>
|
||||
}
|
||||
|
||||
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
|
||||
const [providerID, ...modelID] = input.split("/")
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ import { NpmConfig } from "./npm-config"
|
|||
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
|
||||
add: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
dir: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export interface EntryPoint {
|
||||
readonly directory: string
|
||||
readonly entrypoint: Option.Option<string>
|
||||
readonly entrypoint?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
|
|
@ -34,7 +34,7 @@ export interface Interface {
|
|||
}[]
|
||||
},
|
||||
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
|
||||
|
|
@ -47,12 +47,11 @@ export function sanitize(pkg: string) {
|
|||
}
|
||||
|
||||
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
|
||||
let entrypoint: Option.Option<string>
|
||||
let entrypoint: string | undefined
|
||||
try {
|
||||
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||
entrypoint = Option.some(resolved)
|
||||
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||
} catch {
|
||||
entrypoint = Option.none()
|
||||
entrypoint = undefined
|
||||
}
|
||||
return {
|
||||
directory: dir,
|
||||
|
|
@ -130,7 +129,7 @@ export const layer = Layer.effect(
|
|||
const first = tree.edgesOut.values().next().value?.to
|
||||
if (!first) {
|
||||
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
|
||||
if (Option.isSome(result.entrypoint)) return result
|
||||
if (result.entrypoint) return result
|
||||
return yield* new InstallFailedError({ add: [pkg], dir })
|
||||
}
|
||||
return resolveEntryPoint(first.name, first.path)
|
||||
|
|
@ -219,22 +218,24 @@ export const layer = Layer.effect(
|
|||
return Option.some(files[0])
|
||||
})
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const bin = yield* pick()
|
||||
if (Option.isSome(bin)) {
|
||||
return Option.some(path.join(binDir, bin.value))
|
||||
}
|
||||
return Option.getOrUndefined(
|
||||
yield* Effect.gen(function* () {
|
||||
const bin = yield* pick()
|
||||
if (Option.isSome(bin)) {
|
||||
return Option.some(path.join(binDir, bin.value))
|
||||
}
|
||||
|
||||
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
|
||||
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
|
||||
|
||||
yield* add(pkg)
|
||||
yield* add(pkg)
|
||||
|
||||
const resolved = yield* pick()
|
||||
if (Option.isNone(resolved)) return Option.none<string>()
|
||||
return Option.some(path.join(binDir, resolved.value))
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.orElseSucceed(() => Option.none<string>()),
|
||||
const resolved = yield* pick()
|
||||
if (Option.isNone(resolved)) return Option.none<string>()
|
||||
return Option.some(path.join(binDir, resolved.value))
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.orElseSucceed(() => Option.none<string>()),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -261,14 +262,9 @@ export async function install(...args: Parameters<Interface["install"]>) {
|
|||
}
|
||||
|
||||
export async function add(...args: Parameters<Interface["add"]>) {
|
||||
const entry = await runPromise((svc) => svc.add(...args))
|
||||
return {
|
||||
directory: entry.directory,
|
||||
entrypoint: Option.getOrUndefined(entry.entrypoint),
|
||||
}
|
||||
return runPromise((svc) => svc.add(...args))
|
||||
}
|
||||
|
||||
export async function which(...args: Parameters<Interface["which"]>) {
|
||||
const resolved = await runPromise((svc) => svc.which(...args))
|
||||
return Option.getOrUndefined(resolved)
|
||||
return runPromise((svc) => svc.which(...args))
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue