Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5668fe61f8 | ||
|
|
ed6140af9e | ||
|
|
8d63dc34c0 | ||
|
|
771170fb03 | ||
|
|
a300a6cc7a | ||
|
|
fe594693a4 | ||
|
|
98e091796b | ||
|
|
3c4b4d5faf | ||
|
|
b6ff1b18c7 | ||
|
|
9c9bc09f52 | ||
|
|
d6e06c8950 | ||
|
|
844fb71938 | ||
|
|
fbb7b5b1bf | ||
|
|
95280ebec9 | ||
|
|
fea9a0bd4c | ||
|
|
30c4fcb1a5 | ||
|
|
193c169ca5 | ||
|
|
cee04f2924 | ||
|
|
1219691c11 | ||
|
|
b2cc40f09c | ||
|
|
0b2e65f16d | ||
|
|
1ea01fdad0 | ||
|
|
f8aa4a3be0 | ||
|
|
293bb422fa | ||
|
|
54a78c9224 | ||
|
|
9b30ee2db2 | ||
|
|
ba1ec62caf | ||
|
|
72ec05d0be | ||
|
|
0b702704ae | ||
|
|
3480cef52e | ||
|
|
dcfe4b0d51 | ||
|
|
b2e3dc87ea |
@@ -37,7 +37,6 @@ jobs:
|
||||
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
|
||||
STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }}
|
||||
HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }}
|
||||
INCIDENT_API_KEY: ${{ secrets.INCIDENT_API_KEY }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"provider": {},
|
||||
"permission": {
|
||||
"edit": {
|
||||
"packages/opencode/migration/*": "deny",
|
||||
"packages/opencode/migration/*": "ask",
|
||||
},
|
||||
},
|
||||
"mcp": {},
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -107,7 +107,6 @@
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "0.3.0",
|
||||
"solid-stripe": "0.8.1",
|
||||
"svix": "1.92.2",
|
||||
"vite": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -120,7 +119,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -147,7 +146,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -171,7 +170,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -195,7 +194,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -229,7 +228,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -283,7 +282,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -312,7 +311,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -328,14 +327,14 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
@@ -470,7 +469,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -505,7 +504,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -520,7 +519,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -555,7 +554,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -604,7 +603,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -728,7 +727,7 @@
|
||||
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
|
||||
|
||||
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
||||
|
||||
@@ -2168,8 +2167,6 @@
|
||||
|
||||
"@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="],
|
||||
|
||||
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
|
||||
|
||||
"@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="],
|
||||
|
||||
"@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="],
|
||||
@@ -3180,8 +3177,6 @@
|
||||
|
||||
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
|
||||
|
||||
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
@@ -4656,8 +4651,6 @@
|
||||
|
||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||
|
||||
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
|
||||
|
||||
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
@@ -4726,8 +4719,6 @@
|
||||
|
||||
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
|
||||
|
||||
"svix": ["svix@1.92.2", "", { "dependencies": { "standardwebhooks": "1.0.0" } }, "sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ=="],
|
||||
|
||||
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
|
||||
@@ -30,6 +30,7 @@ export const api = new sst.cloudflare.Worker("Api", {
|
||||
transform: {
|
||||
worker: (args) => {
|
||||
args.logpush = true
|
||||
if ($app.stage === "vimtor") return
|
||||
args.bindings = $resolve(args.bindings).apply((bindings) => [
|
||||
...bindings,
|
||||
{
|
||||
|
||||
+3
-3
@@ -1,5 +1,6 @@
|
||||
import { domain } from "./stage"
|
||||
import { EMAILOCTOPUS_API_KEY } from "./app"
|
||||
import { SECRET } from "./secret"
|
||||
|
||||
////////////////
|
||||
// DATABASE
|
||||
@@ -221,8 +222,6 @@ const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||
const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
|
||||
properties: { value: stripeWebhook.secret },
|
||||
})
|
||||
const INCIDENT_WEBHOOK_SIGNING_SECRET = new sst.Secret("INCIDENT_WEBHOOK_SIGNING_SECRET")
|
||||
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
|
||||
|
||||
const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
|
||||
|
||||
@@ -233,6 +232,7 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
|
||||
const bucket = new sst.cloudflare.Bucket("ZenData")
|
||||
const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||
|
||||
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
|
||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||
|
||||
@@ -254,8 +254,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
database,
|
||||
AUTH_API_URL,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
INCIDENT_WEBHOOK_SIGNING_SECRET,
|
||||
DISCORD_INCIDENT_WEBHOOK_URL,
|
||||
SECRET.HoneycombWebhookSecret,
|
||||
STRIPE_SECRET_KEY,
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
|
||||
+167
-286
@@ -1,318 +1,199 @@
|
||||
const displayName = (s: string) =>
|
||||
s
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ")
|
||||
.replace(/(?<=\d) (?=\d)/g, ".")
|
||||
import { SECRET } from "./secret"
|
||||
import { domain } from "./stage"
|
||||
|
||||
const resourceName = (s: string) => displayName(s).replace(/[^a-zA-Z0-9]/g, "")
|
||||
|
||||
const varSpec = (label: string, name: string) =>
|
||||
$jsonStringify({
|
||||
content: [
|
||||
{
|
||||
content: [
|
||||
{
|
||||
attrs: {
|
||||
name,
|
||||
label,
|
||||
missing: false,
|
||||
},
|
||||
type: "varSpec",
|
||||
},
|
||||
],
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
type: "doc",
|
||||
})
|
||||
|
||||
const fields = {
|
||||
model: incident.getAlertAttributeOutput({ name: "Model" }),
|
||||
product: incident.getAlertAttributeOutput({ name: "Product" }),
|
||||
}
|
||||
|
||||
const alertSource = new incident.AlertSource("HoneycombAlertSource", {
|
||||
name: $app.stage === "production" ? "Honeycomb" : `Honeycomb (${$app.stage})`,
|
||||
sourceType: "honeycomb",
|
||||
template: {
|
||||
title: {
|
||||
literal: varSpec("Payload -> Title", "title"),
|
||||
},
|
||||
description: {
|
||||
literal: varSpec("Payload -> Description", "description"),
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
alertAttributeId: fields.model.id,
|
||||
binding: {
|
||||
value: {
|
||||
reference: 'expressions["model"]',
|
||||
},
|
||||
mergeStrategy: "first_wins",
|
||||
},
|
||||
},
|
||||
{
|
||||
alertAttributeId: fields.product.id,
|
||||
binding: {
|
||||
value: {
|
||||
reference: 'expressions["product"]',
|
||||
},
|
||||
mergeStrategy: "first_wins",
|
||||
},
|
||||
},
|
||||
],
|
||||
expressions: [
|
||||
{
|
||||
label: "Model",
|
||||
operations: [
|
||||
{
|
||||
operationType: "parse",
|
||||
parse: {
|
||||
returns: {
|
||||
array: false,
|
||||
type: fields.model.type,
|
||||
},
|
||||
source: "$['model']",
|
||||
},
|
||||
},
|
||||
],
|
||||
reference: "model",
|
||||
rootReference: "payload",
|
||||
},
|
||||
{
|
||||
label: "Product",
|
||||
operations: [
|
||||
{
|
||||
operationType: "parse",
|
||||
parse: {
|
||||
returns: {
|
||||
array: false,
|
||||
type: fields.product.type,
|
||||
},
|
||||
source: "$['product']",
|
||||
},
|
||||
},
|
||||
],
|
||||
reference: "product",
|
||||
rootReference: "payload",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const webhookRecipient = new honeycomb.WebhookRecipient(`IncidentWebhook`, {
|
||||
name: $app.stage === "production" ? "Incident.io" : `Incident.io (${$app.stage})`,
|
||||
url: alertSource.alertEventsUrl,
|
||||
secret: alertSource.secretToken,
|
||||
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
|
||||
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
|
||||
url: `https://${domain}/honeycomb/webhook`,
|
||||
secret: SECRET.HoneycombWebhookSecret.result,
|
||||
templates: [
|
||||
{
|
||||
type: "trigger",
|
||||
body: $jsonStringify({
|
||||
title: "{{ .Name }}",
|
||||
description: "{{ .Description }}",
|
||||
status: "{{ .Alert.Status }}",
|
||||
deduplication_key: "{{ .Alert.InstanceID }}",
|
||||
source_url: "{{ .Result.URL }}",
|
||||
model: "{{ .Vars.model }}",
|
||||
product: "{{ .Vars.product }}",
|
||||
}),
|
||||
body: `{
|
||||
"url": {{ .Result.URL | quote }},
|
||||
"type": {{ .Vars.type | quote }},
|
||||
"name": {{ .Name | quote }},
|
||||
"status": {{ .Alert.Status | quote }},
|
||||
"isTest": {{ .Alert.IsTest }},
|
||||
"groups": {{ .Result.GroupsTriggered | toJson }}
|
||||
}`,
|
||||
},
|
||||
],
|
||||
variables: [
|
||||
{
|
||||
name: "model",
|
||||
},
|
||||
{
|
||||
name: "product",
|
||||
name: "type",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
new incident.AlertRoute("HoneycombAlertRoute", {
|
||||
name: $app.stage === "production" ? "Honeycomb" : `Honeycomb (${$app.stage})`,
|
||||
enabled: true,
|
||||
isPrivate: false,
|
||||
alertSources: [
|
||||
{
|
||||
alertSourceId: alertSource.id,
|
||||
conditionGroups: [
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
subject: "alert.title",
|
||||
operation: "is_set",
|
||||
paramBindings: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
conditionGroups: [
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
subject: "alert.title",
|
||||
operation: "is_set",
|
||||
paramBindings: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
expressions: [],
|
||||
escalationConfig: {
|
||||
autoCancelEscalations: true,
|
||||
escalationTargets: [],
|
||||
},
|
||||
incidentConfig: {
|
||||
autoDeclineEnabled: true,
|
||||
enabled: true,
|
||||
conditionGroups: [],
|
||||
deferTimeSeconds: 0,
|
||||
groupingKeys: [
|
||||
const modelHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
const filters = [
|
||||
{ column: "model", op: "exists" },
|
||||
{ column: "event_type", op: "=", value: "completions" },
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||
]
|
||||
|
||||
return honeycomb.getQuerySpecificationOutput({
|
||||
breakdowns: ["model"],
|
||||
calculatedFields: [
|
||||
{
|
||||
reference: $interpolate`alert.attributes.${fields.model.id}`,
|
||||
},
|
||||
{
|
||||
reference: $interpolate`alert.attributes.${fields.product.id}`,
|
||||
name: "is_failed_http_status",
|
||||
expression: `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`,
|
||||
},
|
||||
],
|
||||
groupingWindowSeconds: 3600,
|
||||
},
|
||||
incidentTemplate: {
|
||||
name: {
|
||||
value: {
|
||||
literal: varSpec("Alert -> Title", "alert.title"),
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
value: {
|
||||
literal: varSpec("Alert -> Description", "alert.description"),
|
||||
},
|
||||
},
|
||||
startInTriage: {
|
||||
value: {
|
||||
literal: "true",
|
||||
},
|
||||
},
|
||||
severity: {
|
||||
mergeStrategy: "first-wins",
|
||||
},
|
||||
incidentMode: {
|
||||
value: {
|
||||
literal: $app.stage === "production" ? "standard" : "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type Product = "go" | "zen"
|
||||
|
||||
type Trigger = (opts: { model: string; product: Product }) => {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
json: honeycomb.GetQuerySpecificationOutputArgs
|
||||
threshold: { op: ">=" | "<="; value: number }
|
||||
calculations: [
|
||||
{ op: "COUNT", name: "TOTAL", filterCombination: "AND", filters },
|
||||
{ op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters },
|
||||
],
|
||||
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 500), DIV($FAILED, $TOTAL), 0)" }],
|
||||
timeRange: 900,
|
||||
}).json
|
||||
}
|
||||
|
||||
type Model = { id: string; products: Product[]; triggers: Trigger[] }
|
||||
const providerHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
const filters = [
|
||||
{ column: "provider", op: "exists" },
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||
]
|
||||
|
||||
const httpErrors: Trigger = ({ model, product }) => ({
|
||||
id: "increased-http-errors",
|
||||
title: `Increased HTTP Errors for ${displayName(model)} on ${displayName(product)}`,
|
||||
description: `Detected increased rate of HTTP errors for ${displayName(model)} on OpenCode ${displayName(product)}`,
|
||||
json: {
|
||||
return honeycomb.getQuerySpecificationOutput({
|
||||
breakdowns: ["provider"],
|
||||
calculatedFields: [
|
||||
{
|
||||
name: "is_success_http_status",
|
||||
expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`,
|
||||
},
|
||||
{
|
||||
name: "is_failed_provider_http_status",
|
||||
expression: `IF(GTE($llm.error.code, "400"), 1, 0)`,
|
||||
},
|
||||
],
|
||||
calculations: [
|
||||
{
|
||||
op: "COUNT",
|
||||
name: "TOTAL",
|
||||
op: "SUM",
|
||||
name: "SUCCESS",
|
||||
column: "is_success_http_status",
|
||||
filterCombination: "AND",
|
||||
filters: [
|
||||
{ column: "model", op: "=", value: model },
|
||||
{ column: "event_type", op: "=", value: "completions" },
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||
],
|
||||
filters: [...filters, { column: "event_type", op: "=", value: "completions" }],
|
||||
},
|
||||
{
|
||||
op: "COUNT",
|
||||
op: "SUM",
|
||||
name: "FAILED",
|
||||
column: "is_failed_provider_http_status",
|
||||
filterCombination: "AND",
|
||||
filters: [
|
||||
{ column: "model", op: "=", value: model },
|
||||
{ column: "event_type", op: "=", value: "completions" },
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||
{ column: "status", op: ">=", value: "400" },
|
||||
{ column: "status", op: "!=", value: "401" },
|
||||
],
|
||||
filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }],
|
||||
},
|
||||
],
|
||||
formulas: [{ name: "ERROR", expression: "$FAILED / $TOTAL" }],
|
||||
timeRange: 900,
|
||||
},
|
||||
threshold: { op: ">=", value: 0.8 },
|
||||
formulas: [
|
||||
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 250), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
|
||||
],
|
||||
timeRange: 1800,
|
||||
}).json
|
||||
}
|
||||
|
||||
const description = "Managed by SST (Don't edit in Honeycomb UI)"
|
||||
|
||||
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
|
||||
name: "Increased Model HTTP Errors [Go]",
|
||||
description,
|
||||
queryJson: modelHttpErrorsQuery("go"),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "model_http_errors" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const models: Model[] = [
|
||||
{ id: "kimi-k2.6", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
{ id: "kimi-k2.5", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
{ id: "deepseek-v4-flash", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
{ id: "deepseek-v4-pro", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
{ id: "glm-5.1", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
// { id: "glm-5", products: ["go"], triggers: [httpErrors] },
|
||||
{ id: "qwen3.6-plus", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
{ id: "qwen3.5-plus", products: ["go"], triggers: [httpErrors] },
|
||||
{ id: "minimax-m2.7", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
// { id: "minimax-m2.5", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
{ id: "mimo-v2.5-pro", products: ["go"], triggers: [httpErrors] },
|
||||
// { id: "mimo-v2.5", products: ["go"], triggers: [httpErrors] },
|
||||
// { id: "mimo-v2-omni", products: ["go"], triggers: [httpErrors] },
|
||||
// { id: "mimo-v2-pro", products: ["go"], triggers: [httpErrors] },
|
||||
{ id: "claude-opus-4-7", products: ["zen"], triggers: [httpErrors] },
|
||||
// { id: "claude-opus-4-6", products: ["zen"], triggers: [httpErrors] },
|
||||
// { id: "claude-sonnet-4-6", products: ["zen"], triggers: [httpErrors] },
|
||||
{ id: "gpt-5.5", products: ["zen"], triggers: [httpErrors] },
|
||||
{ id: "big-pickle", products: ["zen"], triggers: [httpErrors] },
|
||||
// { id: "minimax-m2.5-free", products: ["zen"], triggers: [httpErrors] },
|
||||
// { id: "hy3-preview-free", products: ["zen"], triggers: [httpErrors] },
|
||||
// { id: "nemotron-3-super-free", products: ["zen"], triggers: [httpErrors] },
|
||||
// { id: "trinity-large-preview-free", products: ["zen"], triggers: [httpErrors] },
|
||||
// { id: "ling-2.6-flash-free", products: ["zen"], triggers: [httpErrors] },
|
||||
]
|
||||
new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
|
||||
name: "Increased Model HTTP Errors [Zen]",
|
||||
description,
|
||||
queryJson: modelHttpErrorsQuery("zen"),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "model_http_errors" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if ($app.stage !== "production") {
|
||||
models.splice(1)
|
||||
}
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", {
|
||||
name: "Increased Provider HTTP Errors [Go]",
|
||||
description,
|
||||
queryJson: providerHttpErrorsQuery("go"),
|
||||
alertType: "on_change",
|
||||
frequency: 600,
|
||||
thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "provider_http_errors" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
for (const model of models) {
|
||||
for (const product of model.products) {
|
||||
for (const trigger of model.triggers) {
|
||||
const spec = trigger({ model: model.id, product })
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", {
|
||||
name: "Increased Provider HTTP Errors [Zen]",
|
||||
description,
|
||||
queryJson: providerHttpErrorsQuery("zen"),
|
||||
alertType: "on_change",
|
||||
frequency: 600,
|
||||
thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "provider_http_errors" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger(resourceName(`${spec.id}-${product}-${model.id}`), {
|
||||
name: spec.title,
|
||||
description: spec.description,
|
||||
queryJson: honeycomb.getQuerySpecificationOutput(spec.json).json,
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
thresholds: [{ ...spec.threshold, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [
|
||||
{ name: "model", value: model.id },
|
||||
{ name: "product", value: product },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
new honeycomb.Trigger("IncreasedFreeTierRequests", {
|
||||
name: "Increased Free Tier Requests",
|
||||
description,
|
||||
queryJson: honeycomb.getQuerySpecificationOutput({
|
||||
calculations: [{ op: "COUNT" }],
|
||||
filters: [
|
||||
{ column: "event_type", op: "=", value: "completions" },
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isFreeTier", op: "=", value: "true" },
|
||||
],
|
||||
timeRange: 3600,
|
||||
}).json,
|
||||
alertType: "on_change",
|
||||
frequency: 900,
|
||||
thresholds: [{ op: ">=", value: 60, exceededLimit: 1 }],
|
||||
baselineDetails: [{ type: "percentage", offsetMinutes: 1440 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "custom" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
sst.Linkable.wrap(random.RandomPassword, (resource) => ({
|
||||
properties: {
|
||||
value: resource.result,
|
||||
},
|
||||
}))
|
||||
|
||||
export const SECRET = {
|
||||
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
|
||||
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
||||
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-ynZFX8eCamzBuVpauYLbju/Cqbt2260JNumMUj79PKA=",
|
||||
"aarch64-linux": "sha256-JCu7JZkdAAHTufWEJRV1gJErKvHFirq+qmVNIRPZ/0w=",
|
||||
"aarch64-darwin": "sha256-9Dkt/poYBpLdtqA6L9pLe6GS435zFGb5rOYWE5rEnjA=",
|
||||
"x86_64-darwin": "sha256-Nd5j28gAcM7+0ETBchjk9VojViHy3N/z2MkdU42YuCg="
|
||||
"x86_64-linux": "sha256-MHeO1KTmjYa+V4ZBYrQq93cYpjnkGfO9e3MOWwkzjVY=",
|
||||
"aarch64-linux": "sha256-EqTRG7DrdKKT7CEvnaNk5VhjTRhlZ9juP9/Nnr3dJ+g=",
|
||||
"aarch64-darwin": "sha256-c8dWd8Pgp5uIAOdYbHIeGKqWfkF/l4Ze7ArYUMvTNkE=",
|
||||
"x86_64-darwin": "sha256-61NpSO0AZ4iZG19RQ6zg0SJec+VQE46WJKOdRrNofT0="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -31,7 +31,6 @@
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "0.3.0",
|
||||
"solid-stripe": "0.8.1",
|
||||
"svix": "1.92.2",
|
||||
"vite": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { z } from "zod"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
|
||||
|
||||
const DISCORD_ALERT_ROLE_ID = "1501447160175136838"
|
||||
|
||||
const basePayload = z.object({
|
||||
name: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
isTest: z.boolean().optional(),
|
||||
url: z.string(),
|
||||
})
|
||||
|
||||
const groups = z.object({ group: z.object({ key: z.string(), value: z.string() }).array() }).array()
|
||||
|
||||
const honeycombWebhookPayload = z.discriminatedUnion("type", [
|
||||
basePayload.extend({
|
||||
type: z.literal("model_http_errors"),
|
||||
groups,
|
||||
}),
|
||||
basePayload.extend({
|
||||
type: z.literal("provider_http_errors"),
|
||||
groups,
|
||||
}),
|
||||
basePayload.extend({
|
||||
type: z.literal("custom"),
|
||||
}),
|
||||
])
|
||||
|
||||
const postDiscordMessage = async (payload: z.infer<typeof honeycombWebhookPayload>) => {
|
||||
const group =
|
||||
payload.type === "model_http_errors" ? "model" : payload.type === "provider_http_errors" ? "provider" : undefined
|
||||
const names = payload.type === "custom" ? [] : payload.groups.flatMap((item) => item.group.map((g) => g.value))
|
||||
|
||||
const content = [
|
||||
`[**${payload.isTest ? "[TEST] " : ""}${payload.name ?? "Honeycomb alert"}**](${payload.url})`,
|
||||
group && names.length > 0 ? `Affected ${group}s:` : undefined,
|
||||
...names.map((name) => `- ${name}`),
|
||||
"",
|
||||
`<@&${DISCORD_ALERT_ROLE_ID}>`,
|
||||
]
|
||||
.filter((line) => line !== undefined)
|
||||
.join("\n")
|
||||
|
||||
return fetch(Resource.DISCORD_INCIDENT_WEBHOOK_URL.value, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
allowed_mentions: { roles: [DISCORD_ALERT_ROLE_ID] },
|
||||
flags: 4,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(input: APIEvent) {
|
||||
const token = input.request.headers.get("X-Honeycomb-Webhook-Token")
|
||||
if (!safeEqual(token ?? "", Resource.HoneycombWebhookSecret.value)) {
|
||||
console.debug("Invalid Honeycomb webhook token")
|
||||
return Response.json({ message: "invalid token" }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await input.request.json()
|
||||
console.log(body, JSON.stringify(body, null, 2))
|
||||
|
||||
const parsed = honeycombWebhookPayload.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error(parsed.error)
|
||||
return Response.json({ message: "invalid payload" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (parsed.data.status !== "TRIGGERED") {
|
||||
console.debug("Skipping resolved alert Honeycomb webhook")
|
||||
return Response.json({ message: "ignored" }, { status: 200 })
|
||||
}
|
||||
|
||||
const response = await postDiscordMessage(parsed.data)
|
||||
if (!response.ok) {
|
||||
return Response.json({ message: "discord webhook failed" }, { status: 502 })
|
||||
}
|
||||
|
||||
return Response.json({ message: "sent" }, { status: 200 })
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Webhook } from "svix"
|
||||
|
||||
const DISCORD_INCIDENT_ROLE_ID = "1501447160175136838"
|
||||
|
||||
type Incident = {
|
||||
mode?: "test" | "standard"
|
||||
name?: string
|
||||
permalink?: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
type IncidentWebhookPayload = {
|
||||
event_type?: string
|
||||
"public_incident.incident_created_v2"?: Incident
|
||||
}
|
||||
|
||||
const verifyWebhook = async (request: Request) => {
|
||||
const body = await request.text()
|
||||
try {
|
||||
return new Webhook(Resource.INCIDENT_WEBHOOK_SIGNING_SECRET.value).verify(
|
||||
body,
|
||||
Object.fromEntries(request.headers.entries()),
|
||||
) as IncidentWebhookPayload
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const postDiscordMessage = async (incident: Incident) => {
|
||||
return fetch(Resource.DISCORD_INCIDENT_WEBHOOK_URL.value, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: [
|
||||
`**${incident.mode === "test" ? "[TEST] " : ""}${incident.name ?? "Incident has been created"}**`,
|
||||
incident.summary,
|
||||
"",
|
||||
`<@&${DISCORD_INCIDENT_ROLE_ID}>`,
|
||||
"",
|
||||
incident.permalink,
|
||||
]
|
||||
.filter((line) => line !== undefined)
|
||||
.join("\n"),
|
||||
allowed_mentions: {
|
||||
roles: [DISCORD_INCIDENT_ROLE_ID],
|
||||
},
|
||||
flags: 4,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(input: APIEvent) {
|
||||
const payload = await verifyWebhook(input.request)
|
||||
if (!payload) {
|
||||
return Response.json({ message: "invalid signature" }, { status: 401 })
|
||||
}
|
||||
|
||||
if (payload.event_type !== "public_incident.incident_created_v2") {
|
||||
return Response.json({ message: "ignored event" }, { status: 200 })
|
||||
}
|
||||
|
||||
const incident = payload["public_incident.incident_created_v2"]
|
||||
if (!incident) {
|
||||
return Response.json({ message: "missing incident" }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await postDiscordMessage(incident)
|
||||
if (!response.ok) {
|
||||
return Response.json({ message: "discord webhook failed" }, { status: 502 })
|
||||
}
|
||||
|
||||
return Response.json({ message: "sent" }, { status: 200 })
|
||||
}
|
||||
@@ -13,4 +13,13 @@ class LimitError extends Error {
|
||||
}
|
||||
export class RateLimitError extends LimitError {}
|
||||
export class FreeUsageLimitError extends LimitError {}
|
||||
export class SubscriptionUsageLimitError extends LimitError {}
|
||||
|
||||
class SubscriptionUsageLimitError extends LimitError {
|
||||
workspace: string
|
||||
constructor(message: string, workspace: string, retryAfter?: number) {
|
||||
super(message, retryAfter)
|
||||
this.workspace = workspace
|
||||
}
|
||||
}
|
||||
export class GoUsageLimitError extends SubscriptionUsageLimitError {}
|
||||
export class BlackUsageLimitError extends SubscriptionUsageLimitError {}
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
ModelError,
|
||||
RateLimitError,
|
||||
FreeUsageLimitError,
|
||||
SubscriptionUsageLimitError,
|
||||
GoUsageLimitError,
|
||||
BlackUsageLimitError,
|
||||
} from "./error"
|
||||
import {
|
||||
buildCostChunk,
|
||||
@@ -395,7 +396,8 @@ export async function handler(
|
||||
if (
|
||||
error instanceof RateLimitError ||
|
||||
error instanceof FreeUsageLimitError ||
|
||||
error instanceof SubscriptionUsageLimitError
|
||||
error instanceof GoUsageLimitError ||
|
||||
error instanceof BlackUsageLimitError
|
||||
) {
|
||||
const headers = new Headers()
|
||||
if (error.retryAfter) {
|
||||
@@ -404,7 +406,14 @@ export async function handler(
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: { type: error.constructor.name, message: error.message },
|
||||
error: {
|
||||
type: error.constructor.name,
|
||||
message: error.message,
|
||||
},
|
||||
metadata:
|
||||
error instanceof GoUsageLimitError || error instanceof BlackUsageLimitError
|
||||
? { workspace: error.workspace }
|
||||
: {},
|
||||
}),
|
||||
{ status: 429, headers },
|
||||
)
|
||||
@@ -693,10 +702,11 @@ export async function handler(
|
||||
timeUpdated: sub.timeFixedUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
throw new BlackUsageLimitError(
|
||||
t("zen.api.error.subscriptionQuotaExceeded", {
|
||||
retryIn: formatRetryTime(result.resetInSec),
|
||||
}),
|
||||
authInfo.workspaceID,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
@@ -711,10 +721,11 @@ export async function handler(
|
||||
timeUpdated: sub.timeRollingUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
throw new BlackUsageLimitError(
|
||||
t("zen.api.error.subscriptionQuotaExceeded", {
|
||||
retryIn: formatRetryTime(result.resetInSec),
|
||||
}),
|
||||
authInfo.workspaceID,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
@@ -739,8 +750,9 @@ export async function handler(
|
||||
timeUpdated: sub.timeWeeklyUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
throw new GoUsageLimitError(
|
||||
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
||||
authInfo.workspaceID,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
@@ -754,8 +766,9 @@ export async function handler(
|
||||
timeSubscribed: sub.timeCreated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
throw new GoUsageLimitError(
|
||||
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
||||
authInfo.workspaceID,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
@@ -769,8 +782,9 @@ export async function handler(
|
||||
timeUpdated: sub.timeRollingUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
throw new GoUsageLimitError(
|
||||
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
||||
authInfo.workspaceID,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { timingSafeEqual } from "node:crypto"
|
||||
|
||||
export function safeEqual(a: string, b: string): boolean {
|
||||
const encoder = new TextEncoder()
|
||||
const aBytes = encoder.encode(a)
|
||||
const bBytes = encoder.encode(b)
|
||||
return aBytes.length === bBytes.length && timingSafeEqual(aBytes, bBytes)
|
||||
}
|
||||
Vendored
+2
-2
@@ -91,8 +91,8 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+2
-2
@@ -91,8 +91,8 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
+2
-2
@@ -91,8 +91,8 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
# OpenCode Desktop
|
||||
|
||||
Native OpenCode desktop app, built with Tauri v2.
|
||||
The OpenCode Desktop app, built with Electron.
|
||||
|
||||
## Development
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run --cwd packages/desktop tauri dev
|
||||
```
|
||||
|
||||
This starts the Vite dev server on http://localhost:1420 and opens the native window.
|
||||
|
||||
If you only want the web dev server (no native shell):
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/desktop dev
|
||||
bun dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
To create a production `dist/` and build the native app bundle:
|
||||
Run the `build` script to build the app's JS assets, then `package` to
|
||||
bundle the assets as an application. The resulting app will be in `dist/`.
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/desktop tauri build
|
||||
bun run build && bun run package
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
|
||||
|
||||
@@ -37,7 +37,7 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: { index: "src/main/index.ts" },
|
||||
input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" },
|
||||
},
|
||||
externalizeDeps: { include: [nodePtyPkg] },
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
||||
import { execFile, execFileSync } from "node:child_process"
|
||||
import { access, readFile, readdir } from "node:fs/promises"
|
||||
import { dirname, extname, join } from "node:path"
|
||||
import util from "node:util"
|
||||
|
||||
export function checkAppExists(appName: string): boolean {
|
||||
const execFilePromise = util.promisify(execFile)
|
||||
|
||||
const exists = (path: string) =>
|
||||
access(path)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
export function checkAppExists(appName: string) {
|
||||
if (process.platform === "win32") return true
|
||||
if (process.platform === "linux") return true
|
||||
return checkMacosApp(appName)
|
||||
}
|
||||
|
||||
export function resolveAppPath(appName: string): string | null {
|
||||
export function resolveAppPath(appName: string) {
|
||||
if (process.platform !== "win32") return appName
|
||||
return resolveWindowsAppPath(appName)
|
||||
}
|
||||
@@ -32,26 +40,25 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string
|
||||
}
|
||||
}
|
||||
|
||||
function checkMacosApp(appName: string) {
|
||||
async function checkMacosApp(appName: string) {
|
||||
const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
|
||||
|
||||
const home = process.env.HOME
|
||||
if (home) locations.push(`${home}/Applications/${appName}.app`)
|
||||
|
||||
if (locations.some((location) => existsSync(location))) return true
|
||||
|
||||
try {
|
||||
execFileSync("which", [appName])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
for (const location of locations) {
|
||||
if (await exists(location)) return true
|
||||
}
|
||||
|
||||
return execFilePromise("which", [appName])
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
function resolveWindowsAppPath(appName: string): string | null {
|
||||
async function resolveWindowsAppPath(appName: string): Promise<string | null> {
|
||||
let output: string
|
||||
try {
|
||||
output = execFileSync("where", [appName]).toString()
|
||||
output = execFilePromise("where", [appName]).toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -66,8 +73,8 @@ function resolveWindowsAppPath(appName: string): string | null {
|
||||
const exe = paths.find((path) => hasExt(path, "exe"))
|
||||
if (exe) return exe
|
||||
|
||||
const resolveCmd = (path: string) => {
|
||||
const content = readFileSync(path, "utf8")
|
||||
const resolveCmd = async (path: string) => {
|
||||
const content = await readFile(path, "utf8")
|
||||
for (const token of content.split('"').map((value: string) => value.trim())) {
|
||||
const lower = token.toLowerCase()
|
||||
if (!lower.includes(".exe")) continue
|
||||
@@ -85,10 +92,10 @@ function resolveWindowsAppPath(appName: string): string | null {
|
||||
return join(current, part)
|
||||
}, base)
|
||||
|
||||
if (existsSync(resolved)) return resolved
|
||||
if (await exists(resolved)) return resolved
|
||||
}
|
||||
|
||||
if (existsSync(token)) return token
|
||||
if (await exists(token)) return token
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -96,20 +103,20 @@ function resolveWindowsAppPath(appName: string): string | null {
|
||||
|
||||
for (const path of paths) {
|
||||
if (hasExt(path, "cmd") || hasExt(path, "bat")) {
|
||||
const resolved = resolveCmd(path)
|
||||
const resolved = await resolveCmd(path)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
if (!extname(path)) {
|
||||
const cmd = `${path}.cmd`
|
||||
if (existsSync(cmd)) {
|
||||
const resolved = resolveCmd(cmd)
|
||||
if (await exists(cmd)) {
|
||||
const resolved = await resolveCmd(cmd)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
const bat = `${path}.bat`
|
||||
if (existsSync(bat)) {
|
||||
const resolved = resolveCmd(bat)
|
||||
if (await exists(bat)) {
|
||||
const resolved = await resolveCmd(bat)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
}
|
||||
@@ -126,7 +133,7 @@ function resolveWindowsAppPath(appName: string): string | null {
|
||||
const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))]
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
for (const entry of await readdir(dir)) {
|
||||
const candidate = join(dir, entry)
|
||||
if (!hasExt(candidate, "exe")) continue
|
||||
const stem = entry.replace(/\.exe$/i, "")
|
||||
|
||||
Vendored
+1
@@ -5,6 +5,7 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare module "virtual:opencode-server" {
|
||||
export namespace Server {
|
||||
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
|
||||
|
||||
@@ -47,7 +47,15 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio
|
||||
import { initLogging } from "./logging"
|
||||
import { parseMarkdown } from "./markdown"
|
||||
import { createMenu } from "./menu"
|
||||
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
|
||||
import {
|
||||
getDefaultServerUrl,
|
||||
getWslConfig,
|
||||
preferAppEnv,
|
||||
setDefaultServerUrl,
|
||||
setWslConfig,
|
||||
spawnLocalServer,
|
||||
type SidecarListener,
|
||||
} from "./server"
|
||||
import {
|
||||
createLoadingWindow,
|
||||
createMainWindow,
|
||||
@@ -55,15 +63,13 @@ import {
|
||||
setBackgroundColor,
|
||||
setDockIcon,
|
||||
} from "./windows"
|
||||
import { drizzle } from "drizzle-orm/node-sqlite/driver"
|
||||
import type { Server } from "virtual:opencode-server"
|
||||
import { migrate } from "./migrate"
|
||||
|
||||
const initEmitter = new EventEmitter()
|
||||
let initStep: InitStep = { phase: "server_waiting" }
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let server: Server.Listener | null = null
|
||||
let server: SidecarListener | null = null
|
||||
const loadingComplete = defer<void>()
|
||||
|
||||
const pendingDeepLinks: string[] = []
|
||||
@@ -107,6 +113,8 @@ function setupApp() {
|
||||
return
|
||||
}
|
||||
|
||||
preferAppEnv(app.getPath("userData"))
|
||||
|
||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
||||
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
|
||||
if (urls.length) {
|
||||
@@ -123,17 +131,16 @@ function setupApp() {
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
killSidecar()
|
||||
void killSidecar()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
killSidecar()
|
||||
void killSidecar()
|
||||
})
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
killSidecar()
|
||||
app.exit(0)
|
||||
void killSidecar().finally(() => app.exit(0))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -184,7 +191,6 @@ function setInitStep(step: InitStep) {
|
||||
|
||||
async function initialize() {
|
||||
const needsMigration = !sqliteFileExists()
|
||||
const sqliteDone = needsMigration ? defer<void>() : undefined
|
||||
let overlay: BrowserWindow | null = null
|
||||
|
||||
const port = await getSidecarPort()
|
||||
@@ -199,31 +205,26 @@ async function initialize() {
|
||||
setInitStep({ phase: "sqlite_waiting" })
|
||||
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
||||
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
|
||||
if (progress.type === "Done") sqliteDone?.resolve()
|
||||
})
|
||||
|
||||
if (needsMigration) {
|
||||
const { Database, JsonMigration } = await import("virtual:opencode-server")
|
||||
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
|
||||
progress: (event: { current: number; total: number }) => {
|
||||
const percent = Math.round(event.current / event.total) * 100
|
||||
initEmitter.emit("sqlite", { type: "InProgress", value: percent })
|
||||
},
|
||||
})
|
||||
initEmitter.emit("sqlite", { type: "Done" })
|
||||
|
||||
sqliteDone?.resolve()
|
||||
}
|
||||
|
||||
if (needsMigration) {
|
||||
await sqliteDone?.promise
|
||||
}
|
||||
|
||||
logger.log("spawning sidecar", { url })
|
||||
const { listener, health } = await spawnLocalServer(hostname, port, password, () => {
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
})
|
||||
const { listener, health } = await spawnLocalServer(
|
||||
hostname,
|
||||
port,
|
||||
password,
|
||||
() => {
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
},
|
||||
{
|
||||
needsMigration,
|
||||
userDataPath: app.getPath("userData"),
|
||||
onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
|
||||
onStdout: (message) => logger.log("sidecar stdout", { message }),
|
||||
onStderr: (message) => logger.warn("sidecar stderr", { message }),
|
||||
onExit: (code) => logger.warn("sidecar exited", { code }),
|
||||
},
|
||||
)
|
||||
server = listener
|
||||
serverReady.resolve({
|
||||
url,
|
||||
@@ -273,9 +274,10 @@ function wireMenu() {
|
||||
},
|
||||
reload: () => mainWindow?.reload(),
|
||||
relaunch: () => {
|
||||
killSidecar()
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
void killSidecar().finally(() => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -304,7 +306,7 @@ registerIpcHandlers({
|
||||
getDisplayBackend: async () => null,
|
||||
setDisplayBackend: async () => undefined,
|
||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||
checkAppExists: async (appName) => checkAppExists(appName),
|
||||
checkAppExists: (appName) => checkAppExists(appName),
|
||||
wslPath: async (path, mode) => wslPath(path, mode),
|
||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||
loadingWindowComplete: () => loadingComplete.resolve(),
|
||||
@@ -314,10 +316,11 @@ registerIpcHandlers({
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
})
|
||||
|
||||
function killSidecar() {
|
||||
async function killSidecar() {
|
||||
if (!server) return
|
||||
server.stop()
|
||||
const current = server
|
||||
server = null
|
||||
await current.stop()
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
@@ -440,7 +443,7 @@ async function installUpdate() {
|
||||
logger.log("installing downloaded update", {
|
||||
version: downloadedUpdateVersion,
|
||||
})
|
||||
killSidecar()
|
||||
await killSidecar()
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const pickerFilters = (ext?: string[]) => {
|
||||
}
|
||||
|
||||
type Deps = {
|
||||
killSidecar: () => void
|
||||
killSidecar: () => Promise<void> | void
|
||||
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
||||
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
|
||||
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
||||
|
||||
@@ -23,6 +23,11 @@ export function createMenu(deps: Deps) {
|
||||
enabled: UPDATER_ENABLED,
|
||||
click: () => deps.checkForUpdates(),
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
accelerator: "Cmd+,",
|
||||
click: () => deps.trigger("settings.open"),
|
||||
},
|
||||
{
|
||||
label: "Reload Webview",
|
||||
click: () => deps.reload(),
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
import { app } from "electron"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { app, utilityProcess } from "electron"
|
||||
import type { Details } from "electron"
|
||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
|
||||
import { getStore } from "./store"
|
||||
import type { SqliteMigrationProgress } from "../preload/types"
|
||||
|
||||
export type WslConfig = { enabled: boolean }
|
||||
|
||||
export type HealthCheck = { wait: Promise<void> }
|
||||
|
||||
type SidecarMessage =
|
||||
| { type: "sqlite"; progress: SqliteMigrationProgress }
|
||||
| { type: "ready" }
|
||||
| { type: "stopped" }
|
||||
| { type: "error"; error: { message: string; stack?: string } }
|
||||
|
||||
export type SidecarListener = { stop: () => Promise<void> }
|
||||
|
||||
const SIDECAR_SERVICE_NAME = "opencode server"
|
||||
const SIDECAR_START_STALL_TIMEOUT = 60_000
|
||||
const SIDECAR_STOP_TIMEOUT = 6_000
|
||||
|
||||
type SpawnLocalServerOptions = {
|
||||
needsMigration: boolean
|
||||
userDataPath: string
|
||||
onSqliteProgress?: (progress: SqliteMigrationProgress) => void
|
||||
onStdout?: (message: string) => void
|
||||
onStderr?: (message: string) => void
|
||||
onExit?: (code: number) => void
|
||||
}
|
||||
|
||||
export function getDefaultServerUrl(): string | null {
|
||||
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
|
||||
return typeof value === "string" ? value : null
|
||||
@@ -30,49 +55,155 @@ export function setWslConfig(config: WslConfig) {
|
||||
getStore().set(WSL_ENABLED_KEY, config.enabled)
|
||||
}
|
||||
|
||||
export async function spawnLocalServer(hostname: string, port: number, password: string, configureEnv?: () => void) {
|
||||
prepareServerEnv(password)
|
||||
export function preferAppEnv(userDataPath: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
Object.assign(
|
||||
process.env,
|
||||
mergeShellEnv(shell ? loadShellEnv(shell) : null, {
|
||||
...process.env,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function spawnLocalServer(
|
||||
hostname: string,
|
||||
port: number,
|
||||
password: string,
|
||||
configureEnv: () => void,
|
||||
options: SpawnLocalServerOptions,
|
||||
) {
|
||||
configureEnv?.()
|
||||
const { Log, Server } = await import("virtual:opencode-server")
|
||||
await Log.init({ level: "WARN" })
|
||||
const listener = await Server.listen({
|
||||
port,
|
||||
hostname,
|
||||
username: "opencode",
|
||||
password,
|
||||
cors: ["oc://renderer"],
|
||||
const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js")
|
||||
const child = utilityProcess.fork(sidecar, [], {
|
||||
cwd: process.cwd(),
|
||||
env: createSidecarEnv(),
|
||||
serviceName: SIDECAR_SERVICE_NAME,
|
||||
stdio: "pipe",
|
||||
})
|
||||
let exited = false
|
||||
const exit = defer<number>()
|
||||
|
||||
const onProcessGone = (_event: unknown, details: Details) => {
|
||||
if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return
|
||||
options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`)
|
||||
}
|
||||
|
||||
app.on("child-process-gone", onProcessGone)
|
||||
child.once("exit", (code) => {
|
||||
exited = true
|
||||
app.off("child-process-gone", onProcessGone)
|
||||
options.onExit?.(code)
|
||||
exit.resolve(code)
|
||||
})
|
||||
child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`))
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd()))
|
||||
child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd()))
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let done = false
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
const fail = (error: Error) => {
|
||||
if (done) return
|
||||
done = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
const refreshTimeout = () => {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`))
|
||||
}, SIDECAR_START_STALL_TIMEOUT)
|
||||
}
|
||||
|
||||
const onMessage = (message: SidecarMessage) => {
|
||||
if (message.type === "sqlite") {
|
||||
refreshTimeout()
|
||||
options.onSqliteProgress?.(message.progress)
|
||||
return
|
||||
}
|
||||
if (message.type === "ready") {
|
||||
if (done) return
|
||||
done = true
|
||||
cleanup()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
if (message.type === "error") {
|
||||
fail(Object.assign(new Error(message.error.message), { stack: message.error.stack }))
|
||||
}
|
||||
}
|
||||
const onExit = (code: number) => {
|
||||
fail(new Error(`Sidecar exited before ready with code ${code}`))
|
||||
}
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
child.off("message", onMessage)
|
||||
child.off("exit", onExit)
|
||||
}
|
||||
|
||||
child.on("message", onMessage)
|
||||
child.on("exit", onExit)
|
||||
refreshTimeout()
|
||||
child.postMessage({
|
||||
type: "start",
|
||||
hostname,
|
||||
port,
|
||||
password,
|
||||
userDataPath: options.userDataPath,
|
||||
needsMigration: options.needsMigration,
|
||||
})
|
||||
}).catch((error) => {
|
||||
if (!exited) child.kill()
|
||||
throw error
|
||||
})
|
||||
|
||||
const wait = (async () => {
|
||||
const url = `http://${hostname}:${port}`
|
||||
let healthy = false
|
||||
const gone = exit.promise.then((code) => {
|
||||
if (healthy) return
|
||||
throw new Error(`Sidecar exited before health check passed with code ${code}`)
|
||||
})
|
||||
|
||||
const ready = async () => {
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
if (await checkHealth(url, password)) return
|
||||
if (await checkHealth(url, password)) {
|
||||
healthy = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ready()
|
||||
await Promise.race([ready(), gone])
|
||||
})()
|
||||
|
||||
return { listener, health: { wait } }
|
||||
}
|
||||
let stopping: Promise<void> | undefined
|
||||
|
||||
function prepareServerEnv(password: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
|
||||
const env = {
|
||||
...process.env,
|
||||
...shellEnv,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
OPENCODE_SERVER_USERNAME: "opencode",
|
||||
OPENCODE_SERVER_PASSWORD: password,
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? app.getPath("userData"),
|
||||
return {
|
||||
listener: {
|
||||
stop: () => {
|
||||
if (stopping) return stopping
|
||||
if (exited) return Promise.resolve()
|
||||
child.postMessage({ type: "stop" })
|
||||
stopping = Promise.race([
|
||||
exit.promise.then(() => undefined),
|
||||
delay(SIDECAR_STOP_TIMEOUT).then(() => {
|
||||
if (!exited) child.kill()
|
||||
}),
|
||||
])
|
||||
return stopping
|
||||
},
|
||||
},
|
||||
health: { wait },
|
||||
}
|
||||
Object.assign(process.env, env)
|
||||
}
|
||||
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
@@ -100,3 +231,31 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function createSidecarEnv(): Record<string, string> {
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
|
||||
)
|
||||
delete env.DEBUG
|
||||
if (process.platform === "linux") delete env.LD_PRELOAD
|
||||
return env
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function serializeError(error: unknown) {
|
||||
if (error instanceof Error) return { message: error.message, stack: error.stack }
|
||||
return { message: String(error) }
|
||||
}
|
||||
|
||||
function defer<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { drizzle } from "drizzle-orm/node-sqlite/driver"
|
||||
import * as http from "node:http"
|
||||
import * as tls from "node:tls"
|
||||
|
||||
type NodeHttpWithEnvProxy = typeof http & {
|
||||
setGlobalProxyFromEnv: () => void
|
||||
}
|
||||
|
||||
type NodeTlsWithSystemCertificates = typeof tls & {
|
||||
getCACertificates: (type: "default" | "system") => string[]
|
||||
setDefaultCACertificates: (certificates: string[]) => void
|
||||
}
|
||||
|
||||
type StartCommand = {
|
||||
type: "start"
|
||||
hostname: string
|
||||
port: number
|
||||
password: string
|
||||
userDataPath: string
|
||||
needsMigration: boolean
|
||||
}
|
||||
|
||||
type StopCommand = { type: "stop" }
|
||||
type SidecarCommand = StartCommand | StopCommand
|
||||
|
||||
type SidecarMessage =
|
||||
| { type: "sqlite"; progress: { type: "InProgress"; value: number } | { type: "Done" } }
|
||||
| { type: "ready" }
|
||||
| { type: "stopped" }
|
||||
| { type: "error"; error: { message: string; stack?: string } }
|
||||
|
||||
type ParentPort = {
|
||||
postMessage(message: SidecarMessage): void
|
||||
on(event: "message", listener: (event: { data: unknown }) => void): void
|
||||
}
|
||||
|
||||
type Listener = {
|
||||
stop(close?: boolean): void | Promise<void>
|
||||
}
|
||||
|
||||
const parentPort = getParentPort()
|
||||
let listener: Listener | undefined
|
||||
|
||||
parentPort.on("message", (event) => {
|
||||
const command = parseCommand(event.data)
|
||||
if (!command) return
|
||||
if (command.type === "stop") {
|
||||
void stop()
|
||||
return
|
||||
}
|
||||
void start(command)
|
||||
})
|
||||
|
||||
async function start(command: StartCommand) {
|
||||
try {
|
||||
prepareSidecarEnv(command.password, command.userDataPath)
|
||||
ensureLoopbackNoProxy()
|
||||
useSystemCertificates()
|
||||
useEnvProxy()
|
||||
const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server")
|
||||
await Log.init({ level: "WARN" })
|
||||
|
||||
if (command.needsMigration) {
|
||||
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
|
||||
progress: (event: { current: number; total: number }) => {
|
||||
parentPort.postMessage({
|
||||
type: "sqlite",
|
||||
progress: {
|
||||
type: "InProgress",
|
||||
value: event.total === 0 ? 100 : Math.round((event.current / event.total) * 100),
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
parentPort.postMessage({ type: "sqlite", progress: { type: "Done" } })
|
||||
}
|
||||
|
||||
listener = await Server.listen({
|
||||
port: command.port,
|
||||
hostname: command.hostname,
|
||||
username: "opencode",
|
||||
password: command.password,
|
||||
cors: ["oc://renderer"],
|
||||
})
|
||||
parentPort.postMessage({ type: "ready" })
|
||||
} catch (error) {
|
||||
parentPort.postMessage({ type: "error", error: serializeError(error) })
|
||||
setImmediate(() => process.exit(1))
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
try {
|
||||
await listener?.stop()
|
||||
} finally {
|
||||
listener = undefined
|
||||
parentPort.postMessage({ type: "stopped" })
|
||||
setImmediate(() => process.exit(0))
|
||||
}
|
||||
}
|
||||
|
||||
function prepareSidecarEnv(password: string, userDataPath: string) {
|
||||
Object.assign(process.env, {
|
||||
OPENCODE_SERVER_USERNAME: "opencode",
|
||||
OPENCODE_SERVER_PASSWORD: password,
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
||||
})
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
const loopback = ["127.0.0.1", "localhost", "::1"]
|
||||
const upsert = (key: string) => {
|
||||
const items = (process.env[key] ?? "")
|
||||
.split(",")
|
||||
.map((value: string) => value.trim())
|
||||
.filter((value: string) => Boolean(value))
|
||||
|
||||
for (const host of loopback) {
|
||||
if (items.some((value: string) => value.toLowerCase() === host)) continue
|
||||
items.push(host)
|
||||
}
|
||||
|
||||
process.env[key] = items.join(",")
|
||||
}
|
||||
|
||||
upsert("NO_PROXY")
|
||||
upsert("no_proxy")
|
||||
}
|
||||
|
||||
function useSystemCertificates() {
|
||||
try {
|
||||
const nodeTls = tls as NodeTlsWithSystemCertificates
|
||||
nodeTls.setDefaultCACertificates([
|
||||
...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]),
|
||||
])
|
||||
} catch (error) {
|
||||
console.warn("failed to load system certificates", error)
|
||||
}
|
||||
}
|
||||
|
||||
function useEnvProxy() {
|
||||
try {
|
||||
;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv()
|
||||
} catch (error) {
|
||||
console.warn("failed to load proxy environment", error)
|
||||
}
|
||||
}
|
||||
|
||||
function parseCommand(value: unknown): SidecarCommand | undefined {
|
||||
if (!value || typeof value !== "object") return
|
||||
const command = value as Partial<StartCommand | StopCommand>
|
||||
if (command.type === "stop") return { type: "stop" }
|
||||
if (command.type !== "start") return
|
||||
if (typeof command.hostname !== "string") return
|
||||
if (typeof command.port !== "number") return
|
||||
if (typeof command.password !== "string") return
|
||||
if (typeof command.userDataPath !== "string") return
|
||||
if (typeof command.needsMigration !== "boolean") return
|
||||
return {
|
||||
type: "start",
|
||||
hostname: command.hostname,
|
||||
port: command.port,
|
||||
password: command.password,
|
||||
userDataPath: command.userDataPath,
|
||||
needsMigration: command.needsMigration,
|
||||
}
|
||||
}
|
||||
|
||||
function serializeError(error: unknown) {
|
||||
if (error instanceof Error) return { message: error.message, stack: error.stack }
|
||||
return { message: String(error) }
|
||||
}
|
||||
|
||||
function getParentPort() {
|
||||
const port = process.parentPort as ParentPort | undefined
|
||||
if (!port) throw new Error("Sidecar parent port unavailable")
|
||||
return port
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
Vendored
+2
-2
@@ -91,8 +91,8 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.39"
|
||||
version = "1.14.41"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
Vendored
+2
-2
@@ -91,8 +91,8 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "27114226-085b-421a-9a40-29b88747e29a",
|
||||
"prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"],
|
||||
"prevIds": ["2ec89846-dcf1-4977-ab5e-244ddc9e3d67"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -37,7 +37,7 @@
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "session_entry",
|
||||
"name": "session_message",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
@@ -598,7 +598,7 @@
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -608,7 +608,7 @@
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -618,7 +618,7 @@
|
||||
"generated": null,
|
||||
"name": "type",
|
||||
"entityType": "columns",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -628,7 +628,7 @@
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -638,7 +638,7 @@
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -648,7 +648,7 @@
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -810,6 +810,26 @@
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "agent",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "model",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
@@ -1122,9 +1142,9 @@
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_entry_session_id_session_id_fk",
|
||||
"name": "fk_session_message_session_id_session_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
@@ -1236,8 +1256,8 @@
|
||||
{
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_entry_pk",
|
||||
"table": "session_entry",
|
||||
"name": "session_message_pk",
|
||||
"table": "session_message",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
@@ -1332,9 +1352,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_entry_session_idx",
|
||||
"name": "session_message_session_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -1350,9 +1370,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_entry_session_type_idx",
|
||||
"name": "session_message_session_type_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -1364,9 +1384,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_entry_time_created_idx",
|
||||
"name": "session_message_time_created_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -80,7 +80,7 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
type AuthenticateRequest,
|
||||
type AuthMethod,
|
||||
type CancelNotification,
|
||||
type CloseSessionRequest,
|
||||
type CloseSessionResponse,
|
||||
type ForkSessionRequest,
|
||||
type ForkSessionResponse,
|
||||
type InitializeRequest,
|
||||
@@ -565,6 +567,7 @@ export class Agent implements ACPAgent {
|
||||
image: true,
|
||||
},
|
||||
sessionCapabilities: {
|
||||
close: {},
|
||||
fork: {},
|
||||
list: {},
|
||||
resume: {},
|
||||
@@ -627,6 +630,9 @@ export class Agent implements ACPAgent {
|
||||
// Store ACP session state
|
||||
await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
|
||||
|
||||
const messages = await this.loadSessionMessages(directory, sessionId)
|
||||
this.restoreSessionStateFromMessages(sessionId, messages)
|
||||
|
||||
log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
|
||||
|
||||
const result = await this.loadSessionMode({
|
||||
@@ -635,39 +641,6 @@ export class Agent implements ACPAgent {
|
||||
sessionId,
|
||||
})
|
||||
|
||||
// Replay session history
|
||||
const messages = await this.sdk.session
|
||||
.messages(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
log.error("unexpected error when fetching message", { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
const lastUser = messages?.findLast((m) => m.info.role === "user")?.info
|
||||
if (lastUser?.role === "user") {
|
||||
result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}`
|
||||
this.sessionManager.setModel(sessionId, {
|
||||
providerID: ProviderID.make(lastUser.model.providerID),
|
||||
modelID: ModelID.make(lastUser.model.modelID),
|
||||
})
|
||||
if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) {
|
||||
result.modes.currentModeId = lastUser.agent
|
||||
this.sessionManager.setMode(sessionId, lastUser.agent)
|
||||
}
|
||||
result.configOptions = buildConfigOptions({
|
||||
currentModelId: result.models.currentModelId,
|
||||
availableModels: result.models.availableModels,
|
||||
modes: result.modes,
|
||||
})
|
||||
}
|
||||
|
||||
for (const msg of messages ?? []) {
|
||||
log.debug("replay message", msg)
|
||||
await this.processMessage(msg)
|
||||
@@ -756,6 +729,9 @@ export class Agent implements ACPAgent {
|
||||
const sessionId = forked.id
|
||||
await this.sessionManager.load(sessionId, directory, mcpServers, model)
|
||||
|
||||
const messages = await this.loadSessionMessages(directory, sessionId)
|
||||
this.restoreSessionStateFromMessages(sessionId, messages)
|
||||
|
||||
log.info("fork_session", { sessionId, mcpServers: mcpServers.length })
|
||||
|
||||
const mode = await this.loadSessionMode({
|
||||
@@ -764,20 +740,6 @@ export class Agent implements ACPAgent {
|
||||
sessionId,
|
||||
})
|
||||
|
||||
const messages = await this.sdk.session
|
||||
.messages(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
log.error("unexpected error when fetching message", { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
for (const msg of messages ?? []) {
|
||||
log.debug("replay message", msg)
|
||||
await this.processMessage(msg)
|
||||
@@ -797,7 +759,7 @@ export class Agent implements ACPAgent {
|
||||
}
|
||||
}
|
||||
|
||||
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
const directory = params.cwd
|
||||
const sessionId = params.sessionId
|
||||
const mcpServers = params.mcpServers ?? []
|
||||
@@ -806,6 +768,9 @@ export class Agent implements ACPAgent {
|
||||
const model = await defaultModel(this.config, directory)
|
||||
await this.sessionManager.load(sessionId, directory, mcpServers, model)
|
||||
|
||||
const messages = await this.loadSessionMessages(directory, sessionId, 20)
|
||||
this.restoreSessionStateFromMessages(sessionId, messages)
|
||||
|
||||
log.info("resume_session", { sessionId, mcpServers: mcpServers.length })
|
||||
|
||||
const result = await this.loadSessionMode({
|
||||
@@ -828,6 +793,27 @@ export class Agent implements ACPAgent {
|
||||
}
|
||||
}
|
||||
|
||||
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
const session = this.sessionManager.remove(params.sessionId)
|
||||
if (!session) return {}
|
||||
|
||||
await this.sdk.session
|
||||
.abort(
|
||||
{
|
||||
sessionID: params.sessionId,
|
||||
directory: session.cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.catch((error) => {
|
||||
log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
|
||||
})
|
||||
|
||||
this.permissionQueues.delete(params.sessionId)
|
||||
log.info("close_session", { sessionId: params.sessionId })
|
||||
return {}
|
||||
}
|
||||
|
||||
private async processMessage(message: SessionMessageResponse) {
|
||||
log.debug("process message", message)
|
||||
if (message.info.role !== "assistant" && message.info.role !== "user") return
|
||||
@@ -1159,23 +1145,26 @@ export class Agent implements ACPAgent {
|
||||
sessionId: string,
|
||||
): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> {
|
||||
const availableModes = await this.loadAvailableModes(directory)
|
||||
const currentModeId =
|
||||
this.sessionManager.get(sessionId).modeId ||
|
||||
(await (async () => {
|
||||
if (!availableModes.length) return undefined
|
||||
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
|
||||
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
|
||||
this.sessionManager.setMode(sessionId, resolvedModeId)
|
||||
return resolvedModeId
|
||||
})())
|
||||
const storedModeId = this.sessionManager.get(sessionId).modeId
|
||||
if (storedModeId && availableModes.some((mode) => mode.id === storedModeId)) {
|
||||
return { availableModes, currentModeId: storedModeId }
|
||||
}
|
||||
|
||||
const currentModeId = await (async () => {
|
||||
if (!availableModes.length) return undefined
|
||||
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
|
||||
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
|
||||
this.sessionManager.setMode(sessionId, resolvedModeId)
|
||||
return resolvedModeId
|
||||
})()
|
||||
|
||||
return { availableModes, currentModeId }
|
||||
}
|
||||
|
||||
private async loadSessionMode(params: LoadSessionRequest) {
|
||||
const directory = params.cwd
|
||||
const model = await defaultModel(this.config, directory)
|
||||
const sessionId = params.sessionId
|
||||
const model = this.sessionManager.get(sessionId).model ?? (await defaultModel(this.config, directory))
|
||||
|
||||
const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers)
|
||||
const entries = sortProvidersByName(providers)
|
||||
@@ -1184,7 +1173,7 @@ export class Agent implements ACPAgent {
|
||||
if (currentVariant && !availableVariants.includes(currentVariant)) {
|
||||
this.sessionManager.setVariant(sessionId, undefined)
|
||||
}
|
||||
const availableModels = buildAvailableModels(entries, { includeVariants: true })
|
||||
const availableModels = buildAvailableModels(entries)
|
||||
const modeState = await this.resolveModeState(directory, sessionId)
|
||||
const currentModeId = modeState.currentModeId
|
||||
const modes = currentModeId
|
||||
@@ -1267,13 +1256,15 @@ export class Agent implements ACPAgent {
|
||||
return {
|
||||
sessionId,
|
||||
models: {
|
||||
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
|
||||
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
|
||||
availableModels,
|
||||
},
|
||||
modes,
|
||||
configOptions: buildConfigOptions({
|
||||
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
|
||||
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
|
||||
availableModels,
|
||||
currentVariant,
|
||||
availableVariants,
|
||||
modes,
|
||||
}),
|
||||
_meta: buildVariantMeta({
|
||||
@@ -1296,6 +1287,24 @@ export class Agent implements ACPAgent {
|
||||
|
||||
const entries = sortProvidersByName(providers)
|
||||
const availableVariants = modelVariantsFromProviders(entries, selection.model)
|
||||
const modeState = await this.resolveModeState(session.cwd, session.id)
|
||||
const modes = modeState.currentModeId
|
||||
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
|
||||
: undefined
|
||||
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId: session.id,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: buildConfigOptions({
|
||||
currentModelId: formatModelIdWithVariant(selection.model, selection.variant, availableVariants, false),
|
||||
availableModels: buildAvailableModels(entries),
|
||||
currentVariant: selection.variant,
|
||||
availableVariants,
|
||||
modes,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
_meta: buildVariantMeta({
|
||||
@@ -1327,6 +1336,14 @@ export class Agent implements ACPAgent {
|
||||
const selection = parseModelSelection(params.value, providers)
|
||||
this.sessionManager.setModel(session.id, selection.model)
|
||||
this.sessionManager.setVariant(session.id, selection.variant)
|
||||
} else if (params.configId === "effort") {
|
||||
if (typeof params.value !== "string") throw RequestError.invalidParams("effort value must be a string")
|
||||
const current = session.model ?? (await defaultModel(this.config, session.cwd))
|
||||
const availableVariants = modelVariantsFromProviders(entries, current)
|
||||
if (!availableVariants.includes(params.value)) {
|
||||
throw RequestError.invalidParams(JSON.stringify({ error: `Effort not found: ${params.value}` }))
|
||||
}
|
||||
this.sessionManager.setVariant(session.id, params.value)
|
||||
} else if (params.configId === "mode") {
|
||||
if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string")
|
||||
const availableModes = await this.loadAvailableModes(session.cwd)
|
||||
@@ -1341,15 +1358,21 @@ export class Agent implements ACPAgent {
|
||||
const updatedSession = this.sessionManager.get(session.id)
|
||||
const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd))
|
||||
const availableVariants = modelVariantsFromProviders(entries, model)
|
||||
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true)
|
||||
const availableModels = buildAvailableModels(entries, { includeVariants: true })
|
||||
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, false)
|
||||
const availableModels = buildAvailableModels(entries)
|
||||
const modeState = await this.resolveModeState(session.cwd, session.id)
|
||||
const modes = modeState.currentModeId
|
||||
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
|
||||
: undefined
|
||||
|
||||
return {
|
||||
configOptions: buildConfigOptions({ currentModelId, availableModels, modes }),
|
||||
configOptions: buildConfigOptions({
|
||||
currentModelId,
|
||||
availableModels,
|
||||
currentVariant: updatedSession.variant,
|
||||
availableVariants,
|
||||
modes,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1546,6 +1569,37 @@ export class Agent implements ACPAgent {
|
||||
{ throwOnError: true },
|
||||
)
|
||||
}
|
||||
|
||||
private async loadSessionMessages(directory: string, sessionId: string, limit?: number) {
|
||||
return this.sdk.session
|
||||
.messages(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
limit,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((error) => {
|
||||
log.error("unexpected error when fetching message", { error })
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
private restoreSessionStateFromMessages(sessionId: string, messages: SessionMessageResponse[] | undefined) {
|
||||
const lastUser = messages?.findLast((message) => message.info.role === "user")?.info
|
||||
if (lastUser?.role !== "user") return
|
||||
|
||||
this.sessionManager.setModel(sessionId, {
|
||||
providerID: ProviderID.make(lastUser.model.providerID),
|
||||
modelID: ModelID.make(lastUser.model.modelID),
|
||||
})
|
||||
this.sessionManager.setVariant(sessionId, lastUser.model.variant)
|
||||
if (lastUser.agent) {
|
||||
this.sessionManager.setMode(sessionId, lastUser.agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toToolKind(toolName: string): ToolKind {
|
||||
@@ -1629,11 +1683,11 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
|
||||
|
||||
if (specified && !providers.length) return specified
|
||||
|
||||
const lastUsed = await lastUsedModel(sdk, directory, providers)
|
||||
if (lastUsed) return lastUsed
|
||||
|
||||
const opencodeProvider = providers.find((p) => p.id === "opencode")
|
||||
if (opencodeProvider) {
|
||||
if (opencodeProvider.models["big-pickle"]) {
|
||||
return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
|
||||
}
|
||||
const [best] = Provider.sort(Object.values(opencodeProvider.models))
|
||||
if (best) {
|
||||
return {
|
||||
@@ -1653,8 +1707,38 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
|
||||
}
|
||||
|
||||
if (specified) return specified
|
||||
throw new Error("No models available")
|
||||
}
|
||||
|
||||
return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
|
||||
async function lastUsedModel(
|
||||
sdk: OpencodeClient,
|
||||
directory: string,
|
||||
providers: Array<{ id: string; models: Record<string, unknown> }>,
|
||||
): Promise<{ providerID: ProviderID; modelID: ModelID } | undefined> {
|
||||
const session = await sdk.session
|
||||
.list({ directory, roots: true, limit: 1 }, { throwOnError: true })
|
||||
.then((x) => x.data?.[0])
|
||||
.catch((error) => {
|
||||
log.error("failed to list sessions for default model", { error })
|
||||
return undefined
|
||||
})
|
||||
if (!session) return
|
||||
|
||||
const lastUser = await sdk.session
|
||||
.messages({ sessionID: session.id, directory, limit: 20 }, { throwOnError: true })
|
||||
.then((x) => x.data?.findLast((message) => message.info.role === "user")?.info)
|
||||
.catch((error) => {
|
||||
log.error("failed to load session messages for default model", { error, sessionID: session.id })
|
||||
return undefined
|
||||
})
|
||||
if (lastUser?.role !== "user") return
|
||||
|
||||
const provider = providers.find((entry) => entry.id === lastUser.model.providerID)
|
||||
if (!provider?.models[lastUser.model.modelID]) return
|
||||
return {
|
||||
providerID: ProviderID.make(lastUser.model.providerID),
|
||||
modelID: ModelID.make(lastUser.model.modelID),
|
||||
}
|
||||
}
|
||||
|
||||
function parseUri(
|
||||
@@ -1757,8 +1841,14 @@ function formatModelIdWithVariant(
|
||||
includeVariant: boolean,
|
||||
) {
|
||||
const base = `${model.providerID}/${model.modelID}`
|
||||
if (!includeVariant || !variant || !availableVariants.includes(variant)) return base
|
||||
return `${base}/${variant}`
|
||||
if (!includeVariant || availableVariants.length === 0) return base
|
||||
const selectedVariant =
|
||||
variant && availableVariants.includes(variant)
|
||||
? variant
|
||||
: availableVariants.includes(DEFAULT_VARIANT_VALUE)
|
||||
? DEFAULT_VARIANT_VALUE
|
||||
: availableVariants[0]
|
||||
return `${base}/${selectedVariant}`
|
||||
}
|
||||
|
||||
function buildVariantMeta(input: {
|
||||
@@ -1810,6 +1900,8 @@ function parseModelSelection(
|
||||
function buildConfigOptions(input: {
|
||||
currentModelId: string
|
||||
availableModels: ModelOption[]
|
||||
currentVariant?: string
|
||||
availableVariants?: string[]
|
||||
modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
|
||||
}): SessionConfigOption[] {
|
||||
const options: SessionConfigOption[] = [
|
||||
@@ -1822,6 +1914,22 @@ function buildConfigOptions(input: {
|
||||
options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })),
|
||||
},
|
||||
]
|
||||
if (input.availableVariants?.length) {
|
||||
options.push({
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue:
|
||||
input.currentVariant && input.availableVariants.includes(input.currentVariant)
|
||||
? input.currentVariant
|
||||
: input.availableVariants.includes(DEFAULT_VARIANT_VALUE)
|
||||
? DEFAULT_VARIANT_VALUE
|
||||
: input.availableVariants[0],
|
||||
options: input.availableVariants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
|
||||
})
|
||||
}
|
||||
if (input.modes) {
|
||||
options.push({
|
||||
id: "mode",
|
||||
@@ -1839,4 +1947,11 @@ function buildConfigOptions(input: {
|
||||
return options
|
||||
}
|
||||
|
||||
function formatVariantName(variant: string) {
|
||||
return variant
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
export * as ACP from "./agent"
|
||||
|
||||
@@ -113,4 +113,10 @@ export class ACPSessionManager {
|
||||
this.sessions.set(sessionId, session)
|
||||
return session
|
||||
}
|
||||
|
||||
remove(sessionId: string): ACPSessionState | undefined {
|
||||
const session = this.sessions.get(sessionId)
|
||||
this.sessions.delete(sessionId)
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,60 @@ const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
google: 5,
|
||||
}
|
||||
|
||||
const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__"
|
||||
const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||
|
||||
type ProviderOptionBase = {
|
||||
title: string
|
||||
value: string
|
||||
description?: string
|
||||
category: string
|
||||
}
|
||||
|
||||
type ProviderOption =
|
||||
| (ProviderOptionBase & {
|
||||
type: "provider"
|
||||
providerID: string
|
||||
})
|
||||
| (ProviderOptionBase & {
|
||||
type: "custom"
|
||||
})
|
||||
|
||||
export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] {
|
||||
return [
|
||||
...pipe(
|
||||
list,
|
||||
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
|
||||
map((provider) => ({
|
||||
type: "provider" as const,
|
||||
title: provider.name,
|
||||
value: provider.id,
|
||||
providerID: provider.id,
|
||||
description: {
|
||||
opencode: "(Recommended)",
|
||||
anthropic: "(API key)",
|
||||
openai: "(ChatGPT Plus/Pro or API key)",
|
||||
"opencode-go": "Low cost subscription for everyone",
|
||||
}[provider.id],
|
||||
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers",
|
||||
})),
|
||||
),
|
||||
{
|
||||
type: "custom",
|
||||
title: "Other",
|
||||
value: CUSTOM_PROVIDER_OPTION_VALUE,
|
||||
description: "Custom provider",
|
||||
category: "Providers",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function normalizeCustomProviderID(value: string) {
|
||||
const providerID = value.trim().replace(/^@ai-sdk\//, "")
|
||||
if (!CUSTOM_PROVIDER_ID.test(providerID)) return
|
||||
return providerID
|
||||
}
|
||||
|
||||
export function createDialogProviderOptions() {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
@@ -32,30 +86,62 @@ export function createDialogProviderOptions() {
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
const onboarded = useConnected()
|
||||
|
||||
async function promptCustomProviderID(): Promise<string | undefined> {
|
||||
const value = await DialogPrompt.show(dialog, "Other", {
|
||||
placeholder: "Provider id",
|
||||
description: () => (
|
||||
<text fg={theme.textMuted}>
|
||||
This only stores a credential. Configure the provider in opencode.json to use it.
|
||||
</text>
|
||||
),
|
||||
})
|
||||
if (value === null) return
|
||||
|
||||
const providerID = normalizeCustomProviderID(value)
|
||||
if (providerID) return providerID
|
||||
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message:
|
||||
"Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores",
|
||||
})
|
||||
return promptCustomProviderID()
|
||||
}
|
||||
|
||||
const options = createMemo(() => {
|
||||
return pipe(
|
||||
sync.data.provider_next.all,
|
||||
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
|
||||
providerOptions(sync.data.provider_next.all),
|
||||
map((provider) => {
|
||||
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id)
|
||||
const connected = sync.data.provider_next.connected.includes(provider.id)
|
||||
if (provider.type === "custom") {
|
||||
return {
|
||||
title: provider.title,
|
||||
value: provider.value,
|
||||
description: provider.description,
|
||||
category: provider.category,
|
||||
async onSelect() {
|
||||
const providerID = await promptCustomProviderID()
|
||||
if (!providerID) return
|
||||
return dialog.replace(() => <ApiMethod providerID={providerID} title="API key" custom />)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const providerID = provider.providerID
|
||||
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID)
|
||||
const connected = sync.data.provider_next.connected.includes(providerID)
|
||||
|
||||
return {
|
||||
title: provider.name,
|
||||
value: provider.id,
|
||||
description: {
|
||||
opencode: "(Recommended)",
|
||||
anthropic: "(API key)",
|
||||
openai: "(ChatGPT Plus/Pro or API key)",
|
||||
"opencode-go": "Low cost subscription for everyone",
|
||||
}[provider.id],
|
||||
title: provider.title,
|
||||
value: provider.value,
|
||||
description: provider.description,
|
||||
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
|
||||
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
|
||||
category: provider.category,
|
||||
gutter: connected && onboarded() ? () => <text fg={theme.success}>✓</text> : undefined,
|
||||
async onSelect() {
|
||||
if (consoleManaged) return
|
||||
|
||||
const methods = sync.data.provider_auth[provider.id] ?? [
|
||||
const methods = sync.data.provider_auth[providerID] ?? [
|
||||
{
|
||||
type: "api",
|
||||
label: "API key",
|
||||
@@ -93,7 +179,7 @@ export function createDialogProviderOptions() {
|
||||
}
|
||||
|
||||
const result = await sdk.client.provider.oauth.authorize({
|
||||
providerID: provider.id,
|
||||
providerID,
|
||||
method: index,
|
||||
inputs,
|
||||
})
|
||||
@@ -107,22 +193,12 @@ export function createDialogProviderOptions() {
|
||||
}
|
||||
if (result.data?.method === "code") {
|
||||
dialog.replace(() => (
|
||||
<CodeMethod
|
||||
providerID={provider.id}
|
||||
title={method.label}
|
||||
index={index}
|
||||
authorization={result.data!}
|
||||
/>
|
||||
<CodeMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
|
||||
))
|
||||
}
|
||||
if (result.data?.method === "auto") {
|
||||
dialog.replace(() => (
|
||||
<AutoMethod
|
||||
providerID={provider.id}
|
||||
title={method.label}
|
||||
index={index}
|
||||
authorization={result.data!}
|
||||
/>
|
||||
<AutoMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -134,7 +210,7 @@ export function createDialogProviderOptions() {
|
||||
metadata = value
|
||||
}
|
||||
return dialog.replace(() => (
|
||||
<ApiMethod providerID={provider.id} title={method.label} metadata={metadata} />
|
||||
<ApiMethod providerID={providerID} title={method.label} metadata={metadata} />
|
||||
))
|
||||
}
|
||||
},
|
||||
@@ -256,11 +332,13 @@ interface ApiMethodProps {
|
||||
providerID: string
|
||||
title: string
|
||||
metadata?: Record<string, string>
|
||||
custom?: boolean
|
||||
}
|
||||
function ApiMethod(props: ApiMethodProps) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
@@ -305,6 +383,14 @@ function ApiMethod(props: ApiMethodProps) {
|
||||
})
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
if (props.custom && !sync.data.provider_next.all.some((provider) => provider.id === props.providerID)) {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: `Saved credential for ${props.providerID}. Configure it in opencode.json to use it.`,
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
dialog.replace(() => <DialogModel providerID={props.providerID} />)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -70,8 +70,10 @@ export function DialogSessionList() {
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
sourceWorkspaceID: session.workspaceID,
|
||||
workspaceID,
|
||||
sessionID: session.id,
|
||||
copyChanges: false,
|
||||
done: list,
|
||||
})
|
||||
}
|
||||
@@ -105,6 +107,7 @@ export function DialogSessionList() {
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warp(selection)
|
||||
|
||||
@@ -3,10 +3,13 @@ import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||
|
||||
type Adapter = {
|
||||
type: string
|
||||
@@ -33,19 +36,14 @@ export type WorkspaceSelection =
|
||||
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
|
||||
type ExistingWorkspaceSelectValue = { workspace: Workspace }
|
||||
|
||||
export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string }>(input: {
|
||||
sessions: readonly { workspaceID?: string; time: { updated: number } }[]
|
||||
get: (workspaceID: string) => WorkspaceInfo | undefined
|
||||
export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string; timeUsed: number | string }>(input: {
|
||||
workspaces: readonly WorkspaceInfo[]
|
||||
status: (workspaceID: string) => string | undefined
|
||||
limit?: number
|
||||
omitWorkspaceID?: string
|
||||
}) {
|
||||
const workspaces = input.sessions
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.flatMap((session) => {
|
||||
const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined
|
||||
return workspace && input.status(workspace.id) === "connected" ? [workspace] : []
|
||||
})
|
||||
.filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index)
|
||||
const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected")
|
||||
const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed))
|
||||
const recent = workspaces.slice(0, input.limit ?? 3)
|
||||
|
||||
return { recent, hasMore: recent.length < workspaces.length }
|
||||
@@ -78,10 +76,13 @@ export async function openWorkspaceSelect(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
project: ReturnType<typeof useProject>
|
||||
toast: ReturnType<typeof useToast>
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
input.dialog.clear()
|
||||
await input.sdk.client.experimental.workspace.syncList().catch(() => undefined)
|
||||
await input.project.workspace.sync().catch(() => undefined)
|
||||
const adapters = await loadWorkspaceAdapters(input)
|
||||
if (!adapters) return
|
||||
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
|
||||
@@ -93,17 +94,29 @@ export async function warpWorkspaceSession(input: {
|
||||
sync: ReturnType<typeof useSync>
|
||||
project: ReturnType<typeof useProject>
|
||||
toast: ReturnType<typeof useToast>
|
||||
sourceWorkspaceID?: string
|
||||
workspaceID: string | null
|
||||
sessionID: string
|
||||
copyChanges: boolean
|
||||
done?: () => void
|
||||
}): Promise<boolean> {
|
||||
const result = await input.sdk.client.experimental.workspace
|
||||
.warp({
|
||||
id: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
copyChanges: input.copyChanges,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
if (!result?.data) {
|
||||
if (result?.error?.name === "VcsApplyError") {
|
||||
await DialogAlert.show(
|
||||
input.dialog,
|
||||
"Unable to Warp Session",
|
||||
"Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.",
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
input.toast.show({
|
||||
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
|
||||
variant: "error",
|
||||
@@ -143,16 +156,31 @@ export async function warpWorkspaceSession(input: {
|
||||
return true
|
||||
}
|
||||
|
||||
export async function confirmWorkspaceFileChanges(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sourceWorkspaceID?: string
|
||||
}) {
|
||||
const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined)
|
||||
const fileChangeChoice = status?.data?.length
|
||||
? await DialogWorkspaceFileChanges.show(input.dialog, status.data)
|
||||
: "no"
|
||||
if (!fileChangeChoice) return
|
||||
return fileChangeChoice === "yes"
|
||||
}
|
||||
|
||||
export function DialogWorkspaceSelect(props: {
|
||||
adapters?: Adapter[]
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const project = useProject()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
|
||||
const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined))
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
@@ -168,9 +196,9 @@ export function DialogWorkspaceSelect(props: {
|
||||
const list = adapters()
|
||||
if (!list) return []
|
||||
const { recent, hasMore } = recentConnectedWorkspaces({
|
||||
sessions: sync.data.session,
|
||||
get: project.workspace.get,
|
||||
workspaces: project.workspace.list(),
|
||||
status: project.workspace.status,
|
||||
omitWorkspaceID: omittedWorkspaceID(),
|
||||
})
|
||||
return [
|
||||
...list.map((adapter) => ({
|
||||
@@ -231,19 +259,25 @@ export function DialogWorkspaceSelect(props: {
|
||||
return
|
||||
}
|
||||
|
||||
dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
|
||||
dialog.replace(() => (
|
||||
<DialogExistingWorkspaceSelect omitWorkspaceID={omittedWorkspaceID()} onSelect={props.onSelect} />
|
||||
))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
|
||||
function DialogExistingWorkspaceSelect(props: {
|
||||
omitWorkspaceID?: string
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
const project = useProject()
|
||||
|
||||
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
|
||||
project.workspace
|
||||
.list()
|
||||
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
|
||||
.filter((workspace) => workspace.id !== props.omitWorkspaceID)
|
||||
.map((workspace: Workspace) => ({
|
||||
title: workspace.name,
|
||||
description: `(${workspace.type})`,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import type { VcsFileStatus } from "@opencode-ai/sdk/v2"
|
||||
import { createMemo, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useTuiConfig } from "../context/tui-config"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
|
||||
const options = ["no", "yes"] as const
|
||||
|
||||
export type WorkspaceFileChangesChoice = (typeof options)[number]
|
||||
|
||||
function statusLabel(status: VcsFileStatus["status"]) {
|
||||
if (status === "added") return "A"
|
||||
if (status === "deleted") return "D"
|
||||
return "M"
|
||||
}
|
||||
|
||||
function changeCountWidth(file: VcsFileStatus) {
|
||||
// The "plus 2" is for spaces
|
||||
return `${file.additions ? `+${file.additions}` : ""}${file.deletions ? ` -${file.deletions}` : ""}`.length + 2
|
||||
}
|
||||
|
||||
export function DialogWorkspaceFileChanges(props: {
|
||||
files: VcsFileStatus[]
|
||||
onSelect: (choice: WorkspaceFileChangesChoice) => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
|
||||
const height = createMemo(() => Math.min(props.files.length, 8))
|
||||
const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0))
|
||||
|
||||
function confirm() {
|
||||
props.onSelect(store.active)
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
confirm()
|
||||
return
|
||||
}
|
||||
if (evt.name === "left") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
const index = options.indexOf(store.active)
|
||||
setStore("active", options[Math.max(index - 1, 0)])
|
||||
return
|
||||
}
|
||||
if (evt.name === "right") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
const index = options.indexOf(store.active)
|
||||
setStore("active", options[Math.min(index + 1, options.length - 1)])
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
File Changes Found
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height={height()}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
<For each={props.files}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<box flexDirection="row" minWidth={0} flexShrink={1}>
|
||||
<box width={2} flexShrink={0}>
|
||||
<text fg={theme.textMuted}>{statusLabel(item.status)}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{Locale.truncateLeft(item.file, fileNameWidth())}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
|
||||
<text>
|
||||
{" "}
|
||||
{item.additions ? <span style={{ fg: theme.diffAdded }}>+{item.additions}</span> : null}
|
||||
{item.deletions ? <span style={{ fg: theme.diffRemoved }}> -{item.deletions}</span> : null}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Do you want to apply these changes after warping?
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
|
||||
<For each={options}>
|
||||
{(item) => (
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={item === store.active ? theme.primary : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item)
|
||||
props.onSelect(item)
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogWorkspaceFileChanges.show = (dialog: DialogContext, files: VcsFileStatus[]) => {
|
||||
return new Promise<WorkspaceFileChangesChoice | undefined>((resolve) => {
|
||||
dialog.replace(
|
||||
() => <DialogWorkspaceFileChanges files={files} onSelect={resolve} />,
|
||||
() => resolve(undefined),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -42,7 +42,12 @@ import { useKV } from "../../context/kv"
|
||||
import { createFadeIn } from "../../util/signal"
|
||||
import { useTextareaKeybindings } from "../textarea-keybindings"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
|
||||
import {
|
||||
confirmWorkspaceFileChanges,
|
||||
openWorkspaceSelect,
|
||||
warpWorkspaceSession,
|
||||
type WorkspaceSelection,
|
||||
} from "../dialog-workspace-create"
|
||||
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
||||
import { useArgs } from "@tui/context/args"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
@@ -230,6 +235,9 @@ export function Prompt(props: PromptProps) {
|
||||
if (selection.type === "new") void createWorkspace(selection)
|
||||
return
|
||||
}
|
||||
const sourceWorkspaceID = project.workspace.current()
|
||||
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
|
||||
if (copyChanges === undefined) return
|
||||
selectWorkspace(selection)
|
||||
dialog.clear()
|
||||
|
||||
@@ -247,8 +255,10 @@ export function Prompt(props: PromptProps) {
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
sourceWorkspaceID,
|
||||
workspaceID: workspace.id,
|
||||
sessionID: props.sessionID,
|
||||
copyChanges,
|
||||
})
|
||||
if (warped) showWarpNotice(workspace.name)
|
||||
}
|
||||
@@ -590,6 +600,7 @@ export function Prompt(props: PromptProps) {
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warpSession(selection)
|
||||
@@ -844,6 +855,7 @@ export function Prompt(props: PromptProps) {
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warpSession(selection)
|
||||
|
||||
@@ -18,22 +18,18 @@ export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter
|
||||
throw new Error(`Unknown workspace adapter: ${type}`)
|
||||
}
|
||||
|
||||
export async function listAdapters(projectID: ProjectID): Promise<WorkspaceAdapterEntry[]> {
|
||||
const builtin = await Promise.all(
|
||||
Object.entries(BUILTIN).map(async ([type, adapter]) => {
|
||||
return {
|
||||
type,
|
||||
name: adapter.name,
|
||||
description: adapter.description,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({
|
||||
export function listAdapters(projectID: ProjectID): WorkspaceAdapterEntry[] {
|
||||
return registeredAdapters(projectID).map(([type, adapter]) => ({
|
||||
type,
|
||||
name: adapter.name,
|
||||
description: adapter.description,
|
||||
}))
|
||||
return [...builtin, ...custom]
|
||||
}
|
||||
|
||||
export function registeredAdapters(projectID: ProjectID): [string, WorkspaceAdapter][] {
|
||||
const adapters = new Map(Object.entries(BUILTIN))
|
||||
for (const [type, adapter] of state.get(projectID)?.entries() ?? []) adapters.set(type, adapter)
|
||||
return [...adapters.entries()]
|
||||
}
|
||||
|
||||
// Plugins can be loaded per-project so we need to scope them. If you
|
||||
|
||||
@@ -3,14 +3,18 @@ import { type WorkspaceAdapter, WorkspaceInfo } from "../types"
|
||||
|
||||
const WorktreeConfig = Schema.Struct({
|
||||
name: WorkspaceInfo.fields.name,
|
||||
branch: Schema.String,
|
||||
branch: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
directory: Schema.String,
|
||||
})
|
||||
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
|
||||
|
||||
async function loadWorktree() {
|
||||
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
|
||||
return { AppRuntime, Worktree }
|
||||
const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([
|
||||
import("@/effect/app-runtime"),
|
||||
import("@/project/instance"),
|
||||
import("@/worktree"),
|
||||
])
|
||||
return { AppRuntime, Instance, Worktree }
|
||||
}
|
||||
|
||||
export const WorktreeAdapter: WorkspaceAdapter = {
|
||||
@@ -34,11 +38,22 @@ export const WorktreeAdapter: WorkspaceAdapter = {
|
||||
svc.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
branch: config.branch,
|
||||
branch: config.branch ?? config.name,
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
async list() {
|
||||
const { AppRuntime, Instance, Worktree } = await loadWorktree()
|
||||
return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({
|
||||
type: "worktree",
|
||||
name: info.name,
|
||||
branch: info.branch ?? null,
|
||||
directory: info.directory,
|
||||
extra: null,
|
||||
projectID: Instance.project.id,
|
||||
}))
|
||||
},
|
||||
async remove(info) {
|
||||
const { AppRuntime, Worktree } = await loadWorktree()
|
||||
const config = decodeWorktreeConfig(info)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Schema } from "effect"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { WorkspaceID } from "./schema"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
@@ -17,6 +17,11 @@ export const WorkspaceInfo = Schema.Struct({
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
|
||||
|
||||
export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"]))
|
||||
.annotate({ identifier: "WorkspaceListedInfo" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type WorkspaceListedInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceListedInfo>>
|
||||
|
||||
export const WorkspaceAdapterEntry = Schema.Struct({
|
||||
type: Schema.String,
|
||||
name: Schema.String,
|
||||
@@ -40,6 +45,7 @@ export type WorkspaceAdapter = {
|
||||
description: string
|
||||
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
|
||||
list?(): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
|
||||
remove(info: WorkspaceInfo): Promise<void>
|
||||
target(info: WorkspaceInfo): Target | Promise<Target>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import type { ProjectID } from "../project/schema"
|
||||
import type { WorkspaceID } from "./schema"
|
||||
@@ -14,4 +14,7 @@ export const WorkspaceTable = sqliteTable("workspace", {
|
||||
.$type<ProjectID>()
|
||||
.notNull()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
time_used: integer()
|
||||
.notNull()
|
||||
.$default(() => Date.now()),
|
||||
})
|
||||
|
||||
@@ -17,8 +17,8 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
import { WorkspaceTable } from "./workspace.sql"
|
||||
import { getAdapter } from "./adapters"
|
||||
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
|
||||
import { getAdapter, registeredAdapters } from "./adapters"
|
||||
import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
|
||||
import { WorkspaceID } from "./schema"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
@@ -31,9 +31,17 @@ import { WorkspaceContext } from "./workspace-context"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod as effectZod, zodObject } from "@/util/effect-zod"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
|
||||
export const Info = WorkspaceInfoSchema
|
||||
export type Info = WorkspaceInfo
|
||||
export const Info = Schema.Struct({
|
||||
...WorkspaceInfoSchema.fields,
|
||||
timeUsed: Schema.Number,
|
||||
})
|
||||
.annotate({ identifier: "Workspace" })
|
||||
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||
export type Info = WorkspaceInfo & { timeUsed: number }
|
||||
|
||||
export const ConnectionStatus = Schema.Struct({
|
||||
workspaceID: WorkspaceID,
|
||||
@@ -66,6 +74,7 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
|
||||
directory: row.directory,
|
||||
extra: row.extra,
|
||||
projectID: row.project_id,
|
||||
timeUsed: row.time_used,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +95,7 @@ export type CreateInput = Schema.Schema.Type<typeof CreateInput>
|
||||
export const SessionWarpInput = Schema.Struct({
|
||||
workspaceID: Schema.NullOr(WorkspaceID),
|
||||
sessionID: SessionID,
|
||||
copyChanges: Schema.optional(Schema.Boolean),
|
||||
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
||||
export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>
|
||||
|
||||
@@ -137,6 +147,7 @@ type SessionWarpError =
|
||||
| WorkspaceNotFoundError
|
||||
| SessionEventsNotFoundError
|
||||
| SessionWarpHttpError
|
||||
| Vcs.PatchApplyError
|
||||
| HttpClientError.HttpClientError
|
||||
type WaitForSyncError = SyncTimeoutError | SyncAbortedError
|
||||
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
|
||||
@@ -145,6 +156,7 @@ export interface Interface {
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
|
||||
readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
|
||||
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
|
||||
readonly syncList: (project: Project.Info) => Effect.Effect<void>
|
||||
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
|
||||
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
|
||||
readonly status: () => Effect.Effect<ConnectionStatus[]>
|
||||
@@ -167,6 +179,7 @@ export const layer = Layer.effect(
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const sync = yield* SyncEvent.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
const connections = new Map<WorkspaceID, ConnectionStatus>()
|
||||
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
|
||||
|
||||
@@ -255,6 +268,66 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const runInWorkspace = <A, E, R>(input: {
|
||||
workspaceID?: WorkspaceID
|
||||
local: () => Effect.Effect<A, E, R>
|
||||
remote: (input: {
|
||||
workspace: Info
|
||||
target: Extract<Target, { type: "remote" }>
|
||||
}) => HttpClientRequest.HttpClientRequest
|
||||
fallback: A
|
||||
response?: "json" | "text"
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
if (!input.workspaceID) return yield* input.local()
|
||||
|
||||
const workspace = yield* get(input.workspaceID)
|
||||
if (!workspace) return input.fallback
|
||||
|
||||
const adapter = getAdapter(workspace.projectID, workspace.type)
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace))
|
||||
|
||||
if (target.type === "local") {
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* store.provide({ directory: target.directory }, input.local())
|
||||
}
|
||||
|
||||
const response = yield* http.execute(input.remote({ workspace, target })).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
log.warn("workspace target request failed", {
|
||||
workspaceID: workspace.id,
|
||||
error: errorData(error),
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!response) return input.fallback
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
|
||||
log.warn("workspace target request failed", {
|
||||
workspaceID: workspace.id,
|
||||
status: response.status,
|
||||
body,
|
||||
})
|
||||
return input.fallback
|
||||
}
|
||||
|
||||
const body = input.response === "text" ? response.text : response.json
|
||||
return yield* body.pipe(
|
||||
Effect.map((result) => result as A),
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
log.warn("workspace target response decode failed", {
|
||||
workspaceID: workspace.id,
|
||||
error: errorData(error),
|
||||
})
|
||||
return input.fallback
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const syncHistory = Effect.fn("Workspace.syncHistory")(function* (
|
||||
space: Info,
|
||||
url: URL | string,
|
||||
@@ -417,7 +490,19 @@ export const layer = Layer.effect(
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
|
||||
|
||||
const adapter = getAdapter(space.projectID, space.type)
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
setStatus(space.id, "error")
|
||||
log.warn("workspace target failed", {
|
||||
workspaceID: space.id,
|
||||
error: errorData(error),
|
||||
})
|
||||
return null
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!target) return
|
||||
|
||||
if (target.type === "local") {
|
||||
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
|
||||
@@ -457,7 +542,13 @@ export const layer = Layer.effect(
|
||||
const id = WorkspaceID.ascending(input.id)
|
||||
const adapter = getAdapter(input.projectID, input.type)
|
||||
const config = yield* EffectBridge.fromPromise(() =>
|
||||
adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }),
|
||||
adapter.configure({
|
||||
...input,
|
||||
id,
|
||||
name: Slug.create(),
|
||||
directory: null,
|
||||
extra: input.extra ?? null,
|
||||
}),
|
||||
)
|
||||
|
||||
const info: Info = {
|
||||
@@ -468,6 +559,7 @@ export const layer = Layer.effect(
|
||||
directory: config.directory ?? null,
|
||||
extra: config.extra ?? null,
|
||||
projectID: input.projectID,
|
||||
timeUsed: Date.now(),
|
||||
}
|
||||
|
||||
yield* db((db) => {
|
||||
@@ -480,6 +572,7 @@ export const layer = Layer.effect(
|
||||
directory: info.directory,
|
||||
extra: info.extra,
|
||||
project_id: info.projectID,
|
||||
time_used: info.timeUsed,
|
||||
})
|
||||
.run()
|
||||
})
|
||||
@@ -557,6 +650,36 @@ export const layer = Layer.effect(
|
||||
}
|
||||
}
|
||||
|
||||
const sourcePatch =
|
||||
input.copyChanges && current?.workspaceID
|
||||
? yield* runInWorkspace({
|
||||
workspaceID: current?.workspaceID ?? undefined,
|
||||
local: () => vcs.diffRaw(),
|
||||
remote: ({ target }) =>
|
||||
HttpClientRequest.get(route(target.url, "/vcs/diff/raw"), {
|
||||
headers: new Headers(target.headers),
|
||||
}),
|
||||
fallback: "",
|
||||
response: "text",
|
||||
}).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))))
|
||||
: ""
|
||||
|
||||
if (sourcePatch) {
|
||||
// Attempt to apply the file changes to the new workspace.
|
||||
// We intentionally do first so if it fails we don't warp
|
||||
// the session.
|
||||
yield* runInWorkspace({
|
||||
workspaceID: input.workspaceID ?? undefined,
|
||||
local: () => vcs.apply({ patch: sourcePatch }),
|
||||
remote: ({ target }) =>
|
||||
HttpClientRequest.post(route(target.url, "/vcs/apply"), {
|
||||
headers: new Headers(target.headers),
|
||||
body: HttpBody.jsonUnsafe({ patch: sourcePatch }),
|
||||
}),
|
||||
fallback: { applied: false },
|
||||
}).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))))
|
||||
}
|
||||
|
||||
if (input.workspaceID === null) {
|
||||
yield* Effect.sync(() =>
|
||||
SyncEvent.run(Session.Event.Updated, {
|
||||
@@ -732,6 +855,63 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const syncList = Effect.fn("Workspace.syncList")(function* (project: Project.Info) {
|
||||
const names = new Set((yield* list(project)).map((workspace) => workspace.name))
|
||||
const discovered = yield* Effect.forEach(
|
||||
registeredAdapters(project.id),
|
||||
([type, adapter]) =>
|
||||
adapter.list
|
||||
? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe(
|
||||
Effect.catchCause((error) =>
|
||||
Effect.sync(() => {
|
||||
log.warn("workspace adapter list failed", { type, error })
|
||||
return []
|
||||
}),
|
||||
),
|
||||
)
|
||||
: Effect.succeed([]),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
|
||||
yield* Effect.forEach(
|
||||
discovered,
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
if (names.has(item.name)) return
|
||||
names.add(item.name)
|
||||
|
||||
const info: Info = {
|
||||
id: WorkspaceID.ascending(),
|
||||
type: item.type,
|
||||
branch: item.branch,
|
||||
name: item.name,
|
||||
directory: item.directory,
|
||||
extra: item.extra,
|
||||
projectID: item.projectID,
|
||||
timeUsed: Date.now(),
|
||||
}
|
||||
|
||||
yield* db((db) => {
|
||||
db.insert(WorkspaceTable)
|
||||
.values({
|
||||
id: info.id,
|
||||
type: info.type,
|
||||
branch: info.branch,
|
||||
name: info.name,
|
||||
directory: info.directory,
|
||||
extra: info.extra,
|
||||
project_id: info.projectID,
|
||||
time_used: info.timeUsed,
|
||||
})
|
||||
.run()
|
||||
})
|
||||
|
||||
yield* startSync(info)
|
||||
}),
|
||||
{ concurrency: 1 },
|
||||
)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) {
|
||||
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
||||
if (!row) return
|
||||
@@ -820,13 +1000,10 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) {
|
||||
// This session table join makes this query only return
|
||||
// workspaces that have sessions
|
||||
const rows = yield* db((db) =>
|
||||
db
|
||||
.selectDistinct({ workspace: WorkspaceTable })
|
||||
.from(WorkspaceTable)
|
||||
.innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id))
|
||||
.where(eq(WorkspaceTable.project_id, projectID))
|
||||
.all(),
|
||||
)
|
||||
@@ -851,6 +1028,7 @@ export const layer = Layer.effect(
|
||||
create,
|
||||
sessionWarp,
|
||||
list,
|
||||
syncList,
|
||||
get,
|
||||
remove,
|
||||
status,
|
||||
@@ -866,6 +1044,8 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SyncEvent.defaultLayer),
|
||||
Layer.provide(SessionPrompt.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(Vcs.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@ export const layer = Layer.effect(
|
||||
cwd: dir,
|
||||
env: item.environment,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface Options {
|
||||
readonly cwd: string
|
||||
readonly env?: Record<string, string>
|
||||
readonly maxOutputBytes?: number
|
||||
readonly stdin?: ChildProcess.CommandInput
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -85,6 +86,7 @@ export interface Interface {
|
||||
readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect<Patch>
|
||||
readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
|
||||
readonly statUntracked: (cwd: string, file: string) => Effect.Effect<Stat | undefined>
|
||||
readonly applyPatch: (cwd: string, patch: string) => Effect.Effect<Result>
|
||||
}
|
||||
|
||||
const kind = (code: string): Kind => {
|
||||
@@ -101,6 +103,8 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const encoder = new TextEncoder()
|
||||
const stdin = (text: string) => Stream.make(encoder.encode(text))
|
||||
|
||||
const run = Effect.fn("Git.run")(
|
||||
function* (args: string[], opts: Options) {
|
||||
@@ -108,7 +112,7 @@ export const layer = Layer.effect(
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
stdin: opts.stdin ?? "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
@@ -316,9 +320,13 @@ export const layer = Layer.effect(
|
||||
cwd,
|
||||
maxOutputBytes: 4096,
|
||||
})
|
||||
|
||||
if (result.truncated) return
|
||||
const parts = result.text().split("\t")
|
||||
const text = result.text()
|
||||
|
||||
const parts = text.split("\t")
|
||||
if (parts.length < 2) return
|
||||
|
||||
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10)
|
||||
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10)
|
||||
return {
|
||||
@@ -328,6 +336,10 @@ export const layer = Layer.effect(
|
||||
} satisfies Stat
|
||||
})
|
||||
|
||||
const applyPatch = Effect.fn("Git.applyPatch")(function* (cwd: string, patch: string) {
|
||||
return yield* run(["apply", "-"], { cwd, stdin: stdin(patch) })
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
run,
|
||||
branch,
|
||||
@@ -343,6 +355,7 @@ export const layer = Layer.effect(
|
||||
patchAll,
|
||||
patchUntracked,
|
||||
statUntracked,
|
||||
applyPatch,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Git } from "@/git"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { zod, zodObject } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "vcs" })
|
||||
@@ -239,11 +239,39 @@ export const FileDiff = Schema.Struct({
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type FileDiff = Schema.Schema.Type<typeof FileDiff>
|
||||
|
||||
export const FileStatus = Schema.Struct({
|
||||
file: Schema.String,
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
status: Schema.Literals(["added", "deleted", "modified"]),
|
||||
})
|
||||
.annotate({ identifier: "VcsFileStatus" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type FileStatus = Schema.Schema.Type<typeof FileStatus>
|
||||
|
||||
export const ApplyInput = Schema.Struct({
|
||||
patch: Schema.String,
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s), zodObject: zodObject(s) })))
|
||||
export type ApplyInput = Schema.Schema.Type<typeof ApplyInput>
|
||||
|
||||
export const ApplyResult = Schema.Struct({
|
||||
applied: Schema.Boolean,
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type ApplyResult = Schema.Schema.Type<typeof ApplyResult>
|
||||
|
||||
export class PatchApplyError extends Schema.TaggedErrorClass<PatchApplyError>()("VcsPatchApplyError", {
|
||||
message: Schema.String,
|
||||
reason: Schema.Literals(["non-git", "not-clean"]),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly branch: () => Effect.Effect<string | undefined>
|
||||
readonly defaultBranch: () => Effect.Effect<string | undefined>
|
||||
readonly status: () => Effect.Effect<FileStatus[]>
|
||||
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
|
||||
readonly diffRaw: () => Effect.Effect<string>
|
||||
readonly apply: (input: ApplyInput) => Effect.Effect<ApplyResult, PatchApplyError>
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -304,6 +332,31 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
|
||||
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
|
||||
return yield* InstanceState.use(state, (x) => x.root?.name)
|
||||
}),
|
||||
status: Effect.fn("Vcs.status")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (ctx.project.vcs !== "git") return []
|
||||
const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
|
||||
const [list, stats] = yield* Effect.all(
|
||||
[git.status(ctx.directory), ref ? git.stats(ctx.directory, ref) : Effect.succeed([])],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const map = nums(stats)
|
||||
return yield* Effect.forEach(
|
||||
list.toSorted((a, b) => a.file.localeCompare(b.file)),
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const stat =
|
||||
map.get(item.file) ??
|
||||
(item.status === "added" ? yield* git.statUntracked(ctx.worktree, item.file) : undefined)
|
||||
return {
|
||||
file: item.file,
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
status: item.status,
|
||||
} satisfies FileStatus
|
||||
}),
|
||||
)
|
||||
}),
|
||||
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
|
||||
const value = yield* InstanceState.get(state)
|
||||
const ctx = yield* InstanceState.context
|
||||
@@ -318,6 +371,36 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
|
||||
if (!ref) return []
|
||||
return yield* diffAgainstRef(git, ctx.directory, ref)
|
||||
}),
|
||||
diffRaw: Effect.fn("Vcs.diffRaw")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (ctx.project.vcs !== "git") return ""
|
||||
const [hasHead, status] = yield* Effect.all([git.hasHead(ctx.directory), git.status(ctx.directory)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
const tracked = hasHead ? (yield* git.patchAll(ctx.directory, "HEAD")).text : ""
|
||||
const untracked = yield* Effect.forEach(
|
||||
status.filter((item) => item.code === "??"),
|
||||
(item) => git.patchUntracked(ctx.directory, item.file).pipe(Effect.map((patch) => patch.text)),
|
||||
)
|
||||
return [tracked, ...untracked].filter(Boolean).join("\n")
|
||||
}),
|
||||
apply: Effect.fn("Vcs.apply")(function* (input: ApplyInput) {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return yield* new PatchApplyError({
|
||||
message: "Patch can't be applied because the project is not git-based",
|
||||
reason: "non-git",
|
||||
})
|
||||
}
|
||||
const applied = yield* git.applyPatch(ctx.directory, input.patch)
|
||||
if (applied.exitCode !== 0) {
|
||||
return yield* new PatchApplyError({
|
||||
message: "Patch can't be applied",
|
||||
reason: "not-clean",
|
||||
})
|
||||
}
|
||||
return { applied: true }
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { WorkspaceAdapterEntry } from "@/control-plane/types"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
|
||||
@@ -92,6 +93,23 @@ export const WorkspaceRoutes = lazy(() =>
|
||||
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project))))
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/sync-list",
|
||||
describeRoute({
|
||||
summary: "Sync workspace list",
|
||||
description: "Register missing workspaces returned by workspace adapters.",
|
||||
operationId: "experimental.workspace.syncList",
|
||||
responses: {
|
||||
204: {
|
||||
description: "Workspace list synced",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.syncList(Instance.project)))
|
||||
return c.body(null, 204)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/status",
|
||||
describeRoute({
|
||||
@@ -164,19 +182,47 @@ export const WorkspaceRoutes = lazy(() =>
|
||||
z.object({
|
||||
id: zodObject(Workspace.Info).shape.id.nullable(),
|
||||
sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID,
|
||||
copyChanges: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
return AppRuntime.runPromise(
|
||||
Workspace.Service.use((workspace) =>
|
||||
workspace.sessionWarp({
|
||||
workspaceID: body.id,
|
||||
sessionID: body.sessionID,
|
||||
copyChanges: body.copyChanges,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.match({
|
||||
onFailure: (error) => {
|
||||
if (error instanceof Vcs.PatchApplyError) {
|
||||
return c.json(
|
||||
{
|
||||
name: "VcsApplyError",
|
||||
data: {
|
||||
message: error.message,
|
||||
reason: error.reason,
|
||||
},
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
name: "WorkspaceWarpError",
|
||||
data: {
|
||||
message: error.message,
|
||||
},
|
||||
},
|
||||
400,
|
||||
)
|
||||
},
|
||||
onSuccess: () => c.body(null, 204),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.body(null, 204)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LSP } from "@/lsp/lsp"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Skill } from "@/skill"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
@@ -23,11 +23,25 @@ export const VcsDiffQuery = Schema.Struct({
|
||||
mode: Vcs.Mode,
|
||||
})
|
||||
|
||||
export class ApiVcsApplyError extends Schema.ErrorClass<ApiVcsApplyError>("VcsApplyError")(
|
||||
{
|
||||
name: Schema.Literal("VcsApplyError"),
|
||||
data: Schema.Struct({
|
||||
message: Schema.String,
|
||||
reason: Schema.Literals(["non-git", "not-clean"]),
|
||||
}),
|
||||
},
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
|
||||
export const InstancePaths = {
|
||||
dispose: "/instance/dispose",
|
||||
path: "/path",
|
||||
vcs: "/vcs",
|
||||
vcsStatus: "/vcs/status",
|
||||
vcsDiff: "/vcs/diff",
|
||||
vcsDiffRaw: "/vcs/diff/raw",
|
||||
vcsApply: "/vcs/apply",
|
||||
command: "/command",
|
||||
agent: "/agent",
|
||||
skill: "/skill",
|
||||
@@ -68,6 +82,15 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
"Retrieve version control system (VCS) information for the current project, such as git branch.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, {
|
||||
success: described(Schema.Array(Vcs.FileStatus), "VCS status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "vcs.status",
|
||||
summary: "Get VCS status",
|
||||
description: "Retrieve changed files in the current working tree without patches.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
|
||||
query: VcsDiffQuery,
|
||||
success: described(Schema.Array(Vcs.FileDiff), "VCS diff"),
|
||||
@@ -78,6 +101,29 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
description: "Retrieve the current git diff for the working tree or against the default branch.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, {
|
||||
success: described(
|
||||
Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })),
|
||||
"Raw VCS diff",
|
||||
),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "vcs.diff.raw",
|
||||
summary: "Get raw VCS diff",
|
||||
description: "Retrieve a raw patch for current uncommitted changes.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, {
|
||||
payload: Vcs.ApplyInput,
|
||||
success: described(Vcs.ApplyResult, "VCS patch applied"),
|
||||
error: ApiVcsApplyError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "vcs.apply",
|
||||
summary: "Apply VCS patch",
|
||||
description: "Apply a raw patch to the current working tree.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("command", InstancePaths.command, {
|
||||
success: described(Schema.Array(Command.Info), "List of commands"),
|
||||
}).annotateMerge(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Workspace } from "@/control-plane/workspace"
|
||||
import { WorkspaceAdapterEntry } from "@/control-plane/types"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { ApiVcsApplyError } from "./instance"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
@@ -12,11 +13,23 @@ export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fie
|
||||
export const WarpPayload = Schema.Struct({
|
||||
id: Schema.NullOr(Workspace.Info.fields.id),
|
||||
sessionID: Workspace.SessionWarpInput.fields.sessionID,
|
||||
copyChanges: Workspace.SessionWarpInput.fields.copyChanges,
|
||||
})
|
||||
|
||||
export class ApiWorkspaceWarpError extends Schema.ErrorClass<ApiWorkspaceWarpError>("WorkspaceWarpError")(
|
||||
{
|
||||
name: Schema.Literal("WorkspaceWarpError"),
|
||||
data: Schema.Struct({
|
||||
message: Schema.String,
|
||||
}),
|
||||
},
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
|
||||
export const WorkspacePaths = {
|
||||
adapters: `${root}/adapter`,
|
||||
list: root,
|
||||
syncList: `${root}/sync-list`,
|
||||
status: `${root}/status`,
|
||||
remove: `${root}/:id`,
|
||||
warp: `${root}/warp`,
|
||||
@@ -55,6 +68,15 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
description: "Create a workspace for the current project.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, {
|
||||
success: described(HttpApiSchema.NoContent, "Workspace list synced"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.syncList",
|
||||
summary: "Sync workspace list",
|
||||
description: "Register missing workspaces returned by workspace adapters.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", WorkspacePaths.status, {
|
||||
success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
|
||||
}).annotateMerge(
|
||||
@@ -78,7 +100,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
|
||||
payload: WarpPayload,
|
||||
success: described(HttpApiSchema.NoContent, "Session warped"),
|
||||
error: HttpApiError.BadRequest,
|
||||
error: [ApiWorkspaceWarpError, ApiVcsApplyError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.warp",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Skill } from "@/skill"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { ApiVcsApplyError } from "../groups/instance"
|
||||
import { markInstanceForDisposal } from "../lifecycle"
|
||||
|
||||
export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) =>
|
||||
@@ -41,10 +42,33 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
|
||||
return { branch, default_branch }
|
||||
})
|
||||
|
||||
const getVcsStatus = Effect.fn("InstanceHttpApi.vcsStatus")(function* () {
|
||||
return yield* vcs.status()
|
||||
})
|
||||
|
||||
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
|
||||
return yield* vcs.diff(ctx.query.mode)
|
||||
})
|
||||
|
||||
const getVcsDiffRaw = Effect.fn("InstanceHttpApi.vcsDiffRaw")(function* () {
|
||||
return yield* vcs.diffRaw()
|
||||
})
|
||||
|
||||
const applyVcs = Effect.fn("InstanceHttpApi.vcsApply")(function* (ctx: { payload: Vcs.ApplyInput }) {
|
||||
return yield* vcs.apply(ctx.payload).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ApiVcsApplyError({
|
||||
name: "VcsApplyError",
|
||||
data: {
|
||||
message: error.message,
|
||||
reason: error.reason,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
|
||||
return yield* command.list()
|
||||
})
|
||||
@@ -69,7 +93,10 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
|
||||
.handle("dispose", dispose)
|
||||
.handle("path", getPath)
|
||||
.handle("vcs", getVcs)
|
||||
.handle("vcsStatus", getVcsStatus)
|
||||
.handle("vcsDiff", getVcsDiff)
|
||||
.handle("vcsDiffRaw", getVcsDiffRaw)
|
||||
.handle("vcsApply", applyVcs)
|
||||
.handle("command", getCommand)
|
||||
.handle("agent", getAgent)
|
||||
.handle("skill", getSkill)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { listAdapters } from "@/control-plane/adapters"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { CreatePayload, WarpPayload } from "../groups/workspace"
|
||||
import { ApiVcsApplyError } from "../groups/instance"
|
||||
import { ApiWorkspaceWarpError, CreatePayload, WarpPayload } from "../groups/workspace"
|
||||
|
||||
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -12,7 +14,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||
|
||||
const adapters = Effect.fn("WorkspaceHttpApi.adapters")(function* () {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() => listAdapters(instance.project.id))
|
||||
return yield* Effect.sync(() => listAdapters(instance.project.id))
|
||||
})
|
||||
|
||||
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
|
||||
@@ -30,6 +32,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
})
|
||||
|
||||
const syncList = Effect.fn("WorkspaceHttpApi.syncList")(function* () {
|
||||
yield* workspace.syncList((yield* InstanceState.context).project)
|
||||
})
|
||||
|
||||
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
|
||||
const ids = new Set((yield* workspace.list((yield* InstanceState.context).project)).map((item) => item.id))
|
||||
return (yield* workspace.status()).filter((item) => ids.has(item.workspaceID))
|
||||
@@ -44,14 +50,34 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||
.sessionWarp({
|
||||
workspaceID: ctx.payload.id,
|
||||
sessionID: ctx.payload.sessionID,
|
||||
copyChanges: ctx.payload.copyChanges,
|
||||
})
|
||||
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
.pipe(
|
||||
Effect.mapError((error) => {
|
||||
if (error instanceof Vcs.PatchApplyError) {
|
||||
return new ApiVcsApplyError({
|
||||
name: "VcsApplyError",
|
||||
data: {
|
||||
message: error.message,
|
||||
reason: error.reason,
|
||||
},
|
||||
})
|
||||
}
|
||||
return new ApiWorkspaceWarpError({
|
||||
name: "WorkspaceWarpError",
|
||||
data: {
|
||||
message: error.message,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("adapters", adapters)
|
||||
.handle("list", list)
|
||||
.handle("create", create)
|
||||
.handle("syncList", syncList)
|
||||
.handle("status", status)
|
||||
.handle("remove", remove)
|
||||
.handle("warp", warp)
|
||||
|
||||
@@ -27,7 +27,7 @@ import { ProviderRoutes } from "./provider"
|
||||
import { EventRoutes } from "./event"
|
||||
import { SyncRoutes } from "./sync"
|
||||
import { InstanceMiddleware } from "./middleware"
|
||||
import { jsonRequest } from "./trace"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
import { ExperimentalHttpApiServer } from "./httpapi/server"
|
||||
import { EventPaths } from "./httpapi/event"
|
||||
import { ExperimentalPaths } from "./httpapi/groups/experimental"
|
||||
@@ -40,6 +40,7 @@ import { SyncPaths } from "./httpapi/groups/sync"
|
||||
import { TuiPaths } from "./httpapi/groups/tui"
|
||||
import { WorkspacePaths } from "./httpapi/groups/workspace"
|
||||
import type { CorsOptions } from "@/server/cors"
|
||||
import { errors } from "@/server/error"
|
||||
|
||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => {
|
||||
const app = new Hono()
|
||||
@@ -86,7 +87,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
|
||||
app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
|
||||
app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.vcsStatus, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.vcsDiffRaw, (c) => handler(c.req.raw, context))
|
||||
app.post(InstancePaths.vcsApply, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.command, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.agent, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.skill, (c) => handler(c.req.raw, context))
|
||||
@@ -288,6 +292,98 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
|
||||
return yield* vcs.diff(c.req.valid("query").mode)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/vcs/status",
|
||||
describeRoute({
|
||||
summary: "Get VCS status",
|
||||
description: "Retrieve changed files in the current working tree without patches.",
|
||||
operationId: "vcs.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "VCS status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Vcs.FileStatus.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("InstanceRoutes.vcs.status", c, function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.status()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/vcs/diff/raw",
|
||||
describeRoute({
|
||||
summary: "Get raw VCS diff",
|
||||
description: "Retrieve a raw patch for current uncommitted changes.",
|
||||
operationId: "vcs.diff.raw",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Raw VCS diff",
|
||||
content: {
|
||||
"text/x-diff": {
|
||||
schema: resolver(z.string()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const patch = await runRequest(
|
||||
"InstanceRoutes.vcs.diffRaw",
|
||||
c,
|
||||
Vcs.Service.use((vcs) => vcs.diffRaw()),
|
||||
)
|
||||
return c.text(patch, 200, { "content-type": "text/x-diff; charset=utf-8" })
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/vcs/apply",
|
||||
describeRoute({
|
||||
summary: "Apply VCS patch",
|
||||
description: "Apply a raw patch to the current working tree.",
|
||||
operationId: "vcs.apply",
|
||||
responses: {
|
||||
200: {
|
||||
description: "VCS patch applied",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Vcs.ApplyResult.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Vcs.ApplyInput.zodObject),
|
||||
async (c) => {
|
||||
const result = await runRequest(
|
||||
"InstanceRoutes.vcs.apply",
|
||||
c,
|
||||
Vcs.Service.use((vcs) => vcs.apply(c.req.valid("json") as Vcs.ApplyInput)).pipe(
|
||||
Effect.match({
|
||||
onFailure: (error) => ({ ok: false as const, error }),
|
||||
onSuccess: (value) => ({ ok: true as const, value }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (result.ok) return c.json(result.value)
|
||||
return c.json(
|
||||
{
|
||||
name: "VcsApplyError",
|
||||
data: {
|
||||
message: result.error.message,
|
||||
reason: result.error.reason,
|
||||
},
|
||||
},
|
||||
400,
|
||||
)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/command",
|
||||
describeRoute({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SyncEvent } from "@/sync"
|
||||
import * as Session from "./session"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||
import { WorkspaceTable } from "@/control-plane/workspace.sql"
|
||||
import { Log } from "@opencode-ai/core/util/log"
|
||||
import nextProjectors from "./projectors-next"
|
||||
|
||||
@@ -69,6 +70,10 @@ export default [
|
||||
db.insert(SessionTable)
|
||||
.values(Session.toRow(data.info as Session.Info))
|
||||
.run()
|
||||
|
||||
if (data.info.workspaceID) {
|
||||
db.update(WorkspaceTable).set({ time_used: Date.now() }).where(eq(WorkspaceTable.id, data.info.workspaceID)).run()
|
||||
}
|
||||
}),
|
||||
|
||||
SyncEvent.project(Session.Event.Updated, (db, data) => {
|
||||
|
||||
@@ -63,6 +63,11 @@ export function truncate(str: string, len: number): string {
|
||||
return str.slice(0, len - 1) + "…"
|
||||
}
|
||||
|
||||
export function truncateLeft(str: string, len: number): string {
|
||||
if (str.length <= len) return str
|
||||
return "…" + str.slice(-(len - 1))
|
||||
}
|
||||
|
||||
export function truncateMiddle(str: string, maxLength: number = 35): string {
|
||||
if (str.length <= maxLength) return str
|
||||
|
||||
|
||||
@@ -117,6 +117,13 @@ export const ResetFailedError = NamedError.create(
|
||||
}),
|
||||
)
|
||||
|
||||
export const ListFailedError = NamedError.create(
|
||||
"WorktreeListFailedError",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
function slugify(input: string) {
|
||||
return input
|
||||
.trim()
|
||||
@@ -149,6 +156,7 @@ export interface Interface {
|
||||
readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
|
||||
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
|
||||
readonly create: (input?: CreateInput) => Effect.Effect<Info>
|
||||
readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[]>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
|
||||
readonly reset: (input: ResetInput) => Effect.Effect<boolean>
|
||||
}
|
||||
@@ -341,6 +349,34 @@ export const layer: Layer.Layer<
|
||||
return undefined
|
||||
})
|
||||
|
||||
const list = Effect.fn("Worktree.list")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return []
|
||||
}
|
||||
|
||||
const result = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
|
||||
if (result.code !== 0) {
|
||||
throw new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" })
|
||||
}
|
||||
|
||||
const primary = yield* canonical(ctx.worktree)
|
||||
const primaryName = pathSvc.basename(primary)
|
||||
return yield* Effect.forEach(parseWorktreeList(result.text), (entry) =>
|
||||
Effect.gen(function* () {
|
||||
if (!entry.path) return undefined
|
||||
const directory = yield* canonical(entry.path)
|
||||
if (directory === primary) return undefined
|
||||
const name = pathSvc.basename(directory)
|
||||
return {
|
||||
name: name === primaryName ? pathSvc.basename(pathSvc.dirname(directory)) : name,
|
||||
directory,
|
||||
...(entry.branch ? { branch: entry.branch.replace(/^refs\/heads\//, "") } : {}),
|
||||
}
|
||||
}),
|
||||
).pipe(Effect.map((items) => items.filter((item) => item !== undefined)))
|
||||
})
|
||||
|
||||
function stopFsmonitor(target: string) {
|
||||
return fs.exists(target).pipe(
|
||||
Effect.orDie,
|
||||
@@ -579,7 +615,7 @@ export const layer: Layer.Layer<
|
||||
return true
|
||||
})
|
||||
|
||||
return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset })
|
||||
return Service.of({ makeWorktreeInfo, createFromInfo, create, list, remove, reset })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -34,10 +34,11 @@ describe("acp.agent interface compliance", () => {
|
||||
"loadSession",
|
||||
"setSessionMode",
|
||||
"authenticate",
|
||||
// Unstable - SDK checks these with unstable_ prefix
|
||||
// Capability-gated methods checked by the SDK router
|
||||
"listSessions",
|
||||
"resumeSession",
|
||||
"closeSession",
|
||||
"unstable_forkSession",
|
||||
"unstable_resumeSession",
|
||||
"unstable_setSessionModel",
|
||||
]
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test"
|
||||
import { recentConnectedWorkspaces } from "../../../../src/cli/cmd/tui/component/dialog-workspace-create"
|
||||
|
||||
describe("recentConnectedWorkspaces", () => {
|
||||
test("returns unique connected workspaces after filtering missing and inactive entries", () => {
|
||||
test("returns connected workspaces sorted by time used", () => {
|
||||
const workspaces = [
|
||||
{ id: "wrk_a", name: "alpha" },
|
||||
{ id: "wrk_b", name: "beta" },
|
||||
{ id: "wrk_c", name: "gamma" },
|
||||
{ id: "wrk_d", name: "delta" },
|
||||
{ id: "wrk_e", name: "epsilon" },
|
||||
{ id: "wrk_a", name: "alpha", timeUsed: 700 },
|
||||
{ id: "wrk_b", name: "beta", timeUsed: 800 },
|
||||
{ id: "wrk_c", name: "gamma", timeUsed: 400 },
|
||||
{ id: "wrk_d", name: "delta", timeUsed: 300 },
|
||||
{ id: "wrk_e", name: "epsilon", timeUsed: 200 },
|
||||
]
|
||||
const status = {
|
||||
wrk_a: "connected",
|
||||
@@ -19,17 +19,7 @@ describe("recentConnectedWorkspaces", () => {
|
||||
} as const
|
||||
|
||||
const { recent } = recentConnectedWorkspaces({
|
||||
sessions: [
|
||||
{ time: { updated: 900 } },
|
||||
{ workspaceID: "wrk_b", time: { updated: 800 } },
|
||||
{ workspaceID: "wrk_a", time: { updated: 700 } },
|
||||
{ workspaceID: "wrk_a", time: { updated: 600 } },
|
||||
{ workspaceID: "wrk_missing", time: { updated: 500 } },
|
||||
{ workspaceID: "wrk_c", time: { updated: 400 } },
|
||||
{ workspaceID: "wrk_d", time: { updated: 300 } },
|
||||
{ workspaceID: "wrk_e", time: { updated: 200 } },
|
||||
],
|
||||
get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID),
|
||||
workspaces,
|
||||
status: (workspaceID) => status[workspaceID as keyof typeof status],
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { normalizeCustomProviderID, providerOptions } from "../../../../src/cli/cmd/tui/component/dialog-provider"
|
||||
|
||||
describe("providerOptions", () => {
|
||||
test("includes a synthetic Other option for custom providers", () => {
|
||||
expect(providerOptions([{ id: "openai", name: "OpenAI" }]).at(-1)).toMatchObject({
|
||||
title: "Other",
|
||||
description: "Custom provider",
|
||||
category: "Providers",
|
||||
})
|
||||
})
|
||||
|
||||
test("does not use Other as the generic provider category", () => {
|
||||
expect(providerOptions([{ id: "mistral", name: "Mistral" }])[0]?.category).toBe("Providers")
|
||||
})
|
||||
|
||||
test("does not collide with a configured provider named other", () => {
|
||||
const values = providerOptions([{ id: "other", name: "Other Provider" }]).map((option) => option.value)
|
||||
expect(new Set(values).size).toBe(values.length)
|
||||
})
|
||||
|
||||
test("normalizes and validates custom provider ids", () => {
|
||||
expect(normalizeCustomProviderID(" custom-provider ")).toBe("custom-provider")
|
||||
expect(normalizeCustomProviderID("custom_provider")).toBe("custom_provider")
|
||||
expect(normalizeCustomProviderID("@ai-sdk/custom-provider")).toBe("custom-provider")
|
||||
expect(normalizeCustomProviderID("-custom-provider")).toBeUndefined()
|
||||
expect(normalizeCustomProviderID("Custom Provider")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "node:fs/promises"
|
||||
import Http from "node:http"
|
||||
import path from "node:path"
|
||||
@@ -27,14 +28,19 @@ import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
|
||||
import * as WorkspaceOld from "../../src/control-plane/workspace"
|
||||
import * as Workspace from "../../src/control-plane/workspace"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const testServerLayer = Layer.mergeAll(
|
||||
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
|
||||
WorkspaceOld.defaultLayer,
|
||||
Workspace.defaultLayer.pipe(
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||
),
|
||||
SessionNs.defaultLayer,
|
||||
)
|
||||
const it = testEffect(testServerLayer)
|
||||
@@ -58,6 +64,7 @@ type RecordedAdapter = {
|
||||
calls: {
|
||||
configure: WorkspaceInfo[]
|
||||
create: RecordedCreate[]
|
||||
list: number
|
||||
remove: WorkspaceInfo[]
|
||||
target: WorkspaceInfo[]
|
||||
}
|
||||
@@ -107,23 +114,37 @@ async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
|
||||
})
|
||||
}
|
||||
|
||||
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
|
||||
const createWorkspace = (input: WorkspaceOld.CreateInput) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
|
||||
const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input)))
|
||||
const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
|
||||
const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
|
||||
const removeWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.remove(id)))
|
||||
const workspaceStatus = () => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.status()))
|
||||
async function initGitRepo(dir: string) {
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await $`git init`.cwd(dir).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(dir).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(dir).quiet()
|
||||
await $`git config user.email "test@opencode.test"`.cwd(dir).quiet()
|
||||
await $`git config user.name "Test"`.cwd(dir).quiet()
|
||||
await fs.writeFile(path.join(dir, "tracked.txt"), "base\n")
|
||||
await $`git add tracked.txt`.cwd(dir).quiet()
|
||||
await $`git commit -m "base"`.cwd(dir).quiet()
|
||||
}
|
||||
|
||||
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, Workspace.Service>) => AppRuntime.runPromise(effect)
|
||||
const createWorkspace = (input: Workspace.CreateInput) =>
|
||||
runWorkspace(Workspace.Service.use((workspace) => workspace.create(input)))
|
||||
const warpWorkspaceSession = (input: Workspace.SessionWarpInput) =>
|
||||
runWorkspace(Workspace.Service.use((workspace) => workspace.sessionWarp(input)))
|
||||
const listWorkspaces = (project: Parameters<Workspace.Interface["list"]>[0]) =>
|
||||
runWorkspace(Workspace.Service.use((workspace) => workspace.list(project)))
|
||||
const syncListWorkspaces = (project: Parameters<Workspace.Interface["syncList"]>[0]) =>
|
||||
runWorkspace(Workspace.Service.use((workspace) => workspace.syncList(project)))
|
||||
const getWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.get(id)))
|
||||
const removeWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.remove(id)))
|
||||
const workspaceStatus = () => runWorkspace(Workspace.Service.use((workspace) => workspace.status()))
|
||||
const isWorkspaceSyncing = (id: WorkspaceID) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.isSyncing(id)))
|
||||
runWorkspace(Workspace.Service.use((workspace) => workspace.isSyncing(id)))
|
||||
const startWorkspaceSyncing = (projectID: ProjectID) => {
|
||||
void runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)))
|
||||
void runWorkspace(Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)))
|
||||
}
|
||||
const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)))
|
||||
runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)))
|
||||
|
||||
function captureGlobalEvents() {
|
||||
const events: GlobalEvent[] = []
|
||||
@@ -169,11 +190,13 @@ function recordedAdapter(input: {
|
||||
target: (info: WorkspaceInfo) => Target | Promise<Target>
|
||||
configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create?: (info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo) => Promise<void>
|
||||
list?: () => Omit<WorkspaceInfo, "id">[] | Promise<Omit<WorkspaceInfo, "id">[]>
|
||||
remove?: (info: WorkspaceInfo) => Promise<void>
|
||||
}): RecordedAdapter {
|
||||
const calls: RecordedAdapter["calls"] = {
|
||||
configure: [],
|
||||
create: [],
|
||||
list: 0,
|
||||
remove: [],
|
||||
target: [],
|
||||
}
|
||||
@@ -195,6 +218,14 @@ function recordedAdapter(input: {
|
||||
})
|
||||
await input.create?.(info, env, from)
|
||||
},
|
||||
...(input.list
|
||||
? {
|
||||
async list() {
|
||||
calls.list += 1
|
||||
return input.list?.() ?? []
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
async remove(info) {
|
||||
calls.remove.push(structuredClone(info))
|
||||
await input.remove?.(info)
|
||||
@@ -254,7 +285,7 @@ function serverUrl() {
|
||||
})
|
||||
}
|
||||
|
||||
function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<WorkspaceInfo>): WorkspaceInfo {
|
||||
function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<Workspace.Info>): Workspace.Info {
|
||||
return {
|
||||
id: input?.id ?? WorkspaceID.ascending(),
|
||||
type,
|
||||
@@ -263,10 +294,11 @@ function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<Works
|
||||
directory: input?.directory ?? null,
|
||||
extra: input?.extra ?? null,
|
||||
projectID,
|
||||
timeUsed: input?.timeUsed ?? Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
function insertWorkspace(info: WorkspaceInfo) {
|
||||
function insertWorkspace(info: Workspace.Info) {
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(WorkspaceTable)
|
||||
@@ -278,6 +310,7 @@ function insertWorkspace(info: WorkspaceInfo) {
|
||||
directory: info.directory,
|
||||
extra: info.extra,
|
||||
project_id: info.projectID,
|
||||
time_used: info.timeUsed,
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
@@ -330,11 +363,11 @@ function sessionUpdatedType() {
|
||||
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
|
||||
}
|
||||
|
||||
describe("workspace-old schemas and exports", () => {
|
||||
describe("workspace schemas and exports", () => {
|
||||
test("keeps the historical event type names", () => {
|
||||
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
|
||||
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
|
||||
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
|
||||
expect(Workspace.Event.Ready.type).toBe("workspace.ready")
|
||||
expect(Workspace.Event.Failed.type).toBe("workspace.failed")
|
||||
expect(Workspace.Event.Status.type).toBe("workspace.status")
|
||||
})
|
||||
|
||||
test("validates create input with workspace id, project id, branch, type, and extra", () => {
|
||||
@@ -346,13 +379,13 @@ describe("workspace-old schemas and exports", () => {
|
||||
extra: { nested: true },
|
||||
}
|
||||
|
||||
expect(WorkspaceOld.CreateInput.zod.parse(input)).toEqual(input)
|
||||
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
|
||||
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
|
||||
expect(Workspace.CreateInput.zod.parse(input)).toEqual(input)
|
||||
expect(() => Workspace.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
|
||||
expect(() => Workspace.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("workspace-old CRUD", () => {
|
||||
describe("workspace CRUD", () => {
|
||||
test("get returns undefined for a missing workspace", async () => {
|
||||
await withInstance(async () => {
|
||||
expect(await getWorkspace(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined()
|
||||
@@ -429,13 +462,22 @@ describe("workspace-old CRUD", () => {
|
||||
directory: targetDir,
|
||||
extra: { configured: true },
|
||||
projectID: Instance.project.id,
|
||||
timeUsed: info.timeUsed,
|
||||
})
|
||||
expect(await getWorkspace(workspaceID)).toEqual(info)
|
||||
expect(await listWorkspaces(Instance.project)).toEqual([info])
|
||||
expect(recorded.calls.configure).toHaveLength(1)
|
||||
expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null })
|
||||
expect(recorded.calls.create).toHaveLength(1)
|
||||
expect(recorded.calls.create[0].info).toEqual(info)
|
||||
expect(recorded.calls.create[0].info).toEqual({
|
||||
id: workspaceID,
|
||||
type,
|
||||
branch: "configured-branch",
|
||||
name: "Configured Name",
|
||||
directory: targetDir,
|
||||
extra: { configured: true },
|
||||
projectID: Instance.project.id,
|
||||
})
|
||||
expect(JSON.parse(recorded.calls.create[0].env.OPENCODE_AUTH_CONTENT ?? "{}")).toEqual({
|
||||
test: { type: "api", key: "secret" },
|
||||
})
|
||||
@@ -514,6 +556,120 @@ describe("workspace-old CRUD", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("syncList registers adapter-listed workspaces that are missing by name", async () => {
|
||||
await withInstance(async (dir) => {
|
||||
const type = unique("list-sync")
|
||||
const existing = workspaceInfo(Instance.project.id, type, {
|
||||
id: WorkspaceID.ascending("wrk_list_sync_existing"),
|
||||
name: "existing",
|
||||
directory: path.join(dir, "existing"),
|
||||
})
|
||||
insertWorkspace(existing)
|
||||
|
||||
const discovered = {
|
||||
type,
|
||||
name: "discovered",
|
||||
branch: "feature/discovered",
|
||||
directory: path.join(dir, "discovered"),
|
||||
extra: { source: "adapter" },
|
||||
projectID: Instance.project.id,
|
||||
}
|
||||
const recorded = recordedAdapter({
|
||||
list() {
|
||||
return [
|
||||
{
|
||||
type,
|
||||
name: existing.name,
|
||||
branch: "ignored",
|
||||
directory: path.join(dir, "ignored"),
|
||||
extra: null,
|
||||
projectID: Instance.project.id,
|
||||
},
|
||||
discovered,
|
||||
]
|
||||
},
|
||||
target(info) {
|
||||
return { type: "local", directory: info.directory ?? dir }
|
||||
},
|
||||
})
|
||||
registerAdapter(Instance.project.id, type, recorded.adapter)
|
||||
|
||||
await syncListWorkspaces(Instance.project)
|
||||
const synced = (await listWorkspaces(Instance.project)).filter((item) => item.name === discovered.name)
|
||||
|
||||
expect(synced).toHaveLength(1)
|
||||
expect(synced[0]).toMatchObject(discovered)
|
||||
expect(synced[0]?.id).toStartWith("wrk_")
|
||||
expect(await listWorkspaces(Instance.project)).toEqual(expect.arrayContaining([existing, synced[0]]))
|
||||
expect(recorded.calls.list).toBe(1)
|
||||
expect(recorded.calls.configure).toHaveLength(0)
|
||||
expect(recorded.calls.create).toHaveLength(0)
|
||||
expect(recorded.calls.target).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
test("syncList calls every registered adapter with a list method", async () => {
|
||||
await withInstance(async (dir) => {
|
||||
const typeA = unique("list-sync-a")
|
||||
const typeB = unique("list-sync-b")
|
||||
const adapterA = recordedAdapter({
|
||||
list() {
|
||||
return [
|
||||
{
|
||||
type: typeA,
|
||||
name: "adapter-a",
|
||||
branch: null,
|
||||
directory: path.join(dir, "adapter-a"),
|
||||
extra: null,
|
||||
projectID: Instance.project.id,
|
||||
},
|
||||
]
|
||||
},
|
||||
target(info) {
|
||||
return { type: "local", directory: info.directory ?? dir }
|
||||
},
|
||||
})
|
||||
const adapterB = recordedAdapter({
|
||||
list() {
|
||||
return [
|
||||
{
|
||||
type: typeB,
|
||||
name: "adapter-b",
|
||||
branch: null,
|
||||
directory: path.join(dir, "adapter-b"),
|
||||
extra: null,
|
||||
projectID: Instance.project.id,
|
||||
},
|
||||
]
|
||||
},
|
||||
target(info) {
|
||||
return { type: "local", directory: info.directory ?? dir }
|
||||
},
|
||||
})
|
||||
const noList = recordedAdapter({
|
||||
target() {
|
||||
return { type: "local", directory: dir }
|
||||
},
|
||||
})
|
||||
registerAdapter(Instance.project.id, typeA, adapterA.adapter)
|
||||
registerAdapter(Instance.project.id, typeB, adapterB.adapter)
|
||||
registerAdapter(Instance.project.id, unique("list-sync-none"), noList.adapter)
|
||||
|
||||
await syncListWorkspaces(Instance.project)
|
||||
const synced = await listWorkspaces(Instance.project)
|
||||
|
||||
expect(
|
||||
synced
|
||||
.filter((item) => item.type === typeA || item.type === typeB)
|
||||
.map((item) => item.name)
|
||||
.toSorted(),
|
||||
).toEqual(["adapter-a", "adapter-b"])
|
||||
expect(adapterA.calls.list).toBe(1)
|
||||
expect(adapterB.calls.list).toBe(1)
|
||||
expect(noList.calls.list).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it.live("remote create connects to routed event and history endpoints", () => {
|
||||
const calls: FetchCall[] = []
|
||||
return Effect.gen(function* () {
|
||||
@@ -539,7 +695,7 @@ describe("workspace-old CRUD", () => {
|
||||
yield* provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const workspace = yield* Workspace.Service
|
||||
const type = unique("remote-create")
|
||||
const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir })
|
||||
registerAdapter(Instance.project.id, type, recorded.adapter)
|
||||
@@ -644,6 +800,33 @@ describe("workspace-old CRUD", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("sessionWarp applies source workspace patch to local target workspace", async () => {
|
||||
await withInstance(async (dir) => {
|
||||
const previousType = unique("warp-patch-prev-local")
|
||||
const targetType = unique("warp-patch-target-local")
|
||||
const previousDir = path.join(dir, "warp-patch-prev-local")
|
||||
const targetDir = path.join(dir, "warp-patch-target-local")
|
||||
await initGitRepo(previousDir)
|
||||
await initGitRepo(targetDir)
|
||||
await fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n")
|
||||
await fs.writeFile(path.join(previousDir, "new.txt"), "new\n")
|
||||
|
||||
const previous = workspaceInfo(Instance.project.id, previousType)
|
||||
const target = workspaceInfo(Instance.project.id, targetType)
|
||||
insertWorkspace(previous)
|
||||
insertWorkspace(target)
|
||||
registerAdapter(Instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter)
|
||||
registerAdapter(Instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter)
|
||||
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
||||
attachSessionToWorkspace(session.id, previous.id)
|
||||
|
||||
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
|
||||
|
||||
expect(await fs.readFile(path.join(targetDir, "tracked.txt"), "utf8")).toBe("changed\n")
|
||||
expect(await fs.readFile(path.join(targetDir, "new.txt"), "utf8")).toBe("new\n")
|
||||
})
|
||||
})
|
||||
|
||||
test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
|
||||
await withInstance(async (dir) => {
|
||||
const previousType = unique("warp-detach-local")
|
||||
@@ -696,10 +879,12 @@ describe("workspace-old CRUD", () => {
|
||||
},
|
||||
])
|
||||
}
|
||||
if (call.url.pathname === "/warp-source/vcs/diff/raw") return HttpServerResponse.text("remote patch")
|
||||
if (call.url.pathname === "/warp-target/sync/replay")
|
||||
return yield* HttpServerResponse.json({ sessionID: "ok" })
|
||||
if (call.url.pathname === "/warp-target/sync/steal")
|
||||
return yield* HttpServerResponse.json({ sessionID: "ok" })
|
||||
if (call.url.pathname === "/warp-target/vcs/apply") return yield* HttpServerResponse.json({ applied: true })
|
||||
return HttpServerResponse.text("unexpected", { status: 500 })
|
||||
}),
|
||||
)
|
||||
@@ -707,7 +892,7 @@ describe("workspace-old CRUD", () => {
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const workspace = yield* Workspace.Service
|
||||
const sessionSvc = yield* SessionNs.Service
|
||||
const previousType = unique("warp-remote-source")
|
||||
const targetType = unique("warp-remote-target")
|
||||
@@ -722,15 +907,18 @@ describe("workspace-old CRUD", () => {
|
||||
historySessionID = session.id
|
||||
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
|
||||
|
||||
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })
|
||||
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
|
||||
|
||||
expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([
|
||||
"POST /warp-source/sync/history",
|
||||
"GET /warp-source/vcs/diff/raw",
|
||||
"POST /warp-target/vcs/apply",
|
||||
"POST /warp-target/sync/replay",
|
||||
"POST /warp-target/sync/steal",
|
||||
])
|
||||
expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 })
|
||||
expect(calls[1].json).toMatchObject({
|
||||
expect(calls[2].json).toEqual({ patch: "remote patch" })
|
||||
expect(calls[3].json).toMatchObject({
|
||||
directory: "remote-target-dir",
|
||||
events: [
|
||||
{
|
||||
@@ -745,7 +933,7 @@ describe("workspace-old CRUD", () => {
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(calls[2].json).toEqual({ sessionID: session.id })
|
||||
expect(calls[4].json).toEqual({ sessionID: session.id })
|
||||
expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
|
||||
expect(sessionSequenceOwner(session.id)).toBe(target.id)
|
||||
}),
|
||||
@@ -755,7 +943,7 @@ describe("workspace-old CRUD", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("workspace-old sync state", () => {
|
||||
describe("workspace sync state", () => {
|
||||
test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => {
|
||||
await withInstance(async (dir) => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
|
||||
@@ -773,35 +961,29 @@ describe("workspace-old sync state", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("startWorkspaceSyncing starts only workspaces with sessions", async () => {
|
||||
test("startWorkspaceSyncing starts all workspaces", async () => {
|
||||
await withInstance(async (dir) => {
|
||||
const withSessionType = unique("with-session")
|
||||
const withoutSessionType = unique("without-session")
|
||||
const withSession = workspaceInfo(Instance.project.id, withSessionType)
|
||||
const withoutSession = workspaceInfo(Instance.project.id, withoutSessionType)
|
||||
const withSessionDir = path.join(dir, "with-session")
|
||||
const withoutSessionDir = path.join(dir, "without-session")
|
||||
await fs.mkdir(withSessionDir, { recursive: true })
|
||||
await fs.mkdir(withoutSessionDir, { recursive: true })
|
||||
insertWorkspace(withSession)
|
||||
insertWorkspace(withoutSession)
|
||||
registerAdapter(Instance.project.id, withSessionType, localAdapter(withSessionDir).adapter)
|
||||
registerAdapter(Instance.project.id, withoutSessionType, localAdapter(withoutSessionDir).adapter)
|
||||
attachSessionToWorkspace(
|
||||
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
|
||||
withSession.id,
|
||||
)
|
||||
const firstType = unique("first")
|
||||
const secondType = unique("second")
|
||||
const first = workspaceInfo(Instance.project.id, firstType)
|
||||
const second = workspaceInfo(Instance.project.id, secondType)
|
||||
await fs.mkdir(path.join(dir, "first"), { recursive: true })
|
||||
await fs.mkdir(path.join(dir, "second"), { recursive: true })
|
||||
insertWorkspace(first)
|
||||
insertWorkspace(second)
|
||||
registerAdapter(Instance.project.id, firstType, localAdapter(path.join(dir, "first")).adapter)
|
||||
registerAdapter(Instance.project.id, secondType, localAdapter(path.join(dir, "second")).adapter)
|
||||
|
||||
startWorkspaceSyncing(Instance.project.id)
|
||||
|
||||
await eventually(() =>
|
||||
workspaceStatus().then((status) =>
|
||||
expect(status.find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"),
|
||||
),
|
||||
workspaceStatus().then((status) => {
|
||||
expect(status.find((item) => item.workspaceID === first.id)?.status).toBe("connected")
|
||||
expect(status.find((item) => item.workspaceID === second.id)?.status).toBe("connected")
|
||||
}),
|
||||
)
|
||||
expect((await workspaceStatus()).find((item) => item.workspaceID === withoutSession.id)?.status).toBeUndefined()
|
||||
await removeWorkspace(withSession.id)
|
||||
await removeWorkspace(withoutSession.id)
|
||||
await removeWorkspace(first.id)
|
||||
await removeWorkspace(second.id)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -857,7 +1039,7 @@ describe("workspace-old sync state", () => {
|
||||
)
|
||||
expect(
|
||||
captured.events.filter(
|
||||
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type,
|
||||
(event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type,
|
||||
),
|
||||
).toHaveLength(1)
|
||||
await removeWorkspace(info.id)
|
||||
@@ -891,7 +1073,7 @@ describe("workspace-old sync state", () => {
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const workspace = yield* Workspace.Service
|
||||
const sessionSvc = yield* SessionNs.Service
|
||||
const captured = captureGlobalEvents()
|
||||
try {
|
||||
@@ -915,7 +1097,7 @@ describe("workspace-old sync state", () => {
|
||||
expect(
|
||||
captured.events
|
||||
.filter(
|
||||
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type,
|
||||
(event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type,
|
||||
)
|
||||
.map((event) => event.payload.properties.status),
|
||||
).toEqual(["disconnected", "connecting", "connected"])
|
||||
@@ -948,7 +1130,7 @@ describe("workspace-old sync state", () => {
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const workspace = yield* Workspace.Service
|
||||
const sessionSvc = yield* SessionNs.Service
|
||||
const type = unique("remote-connect-fail")
|
||||
const info = workspaceInfo(Instance.project.id, type)
|
||||
@@ -988,7 +1170,7 @@ describe("workspace-old sync state", () => {
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const workspace = yield* Workspace.Service
|
||||
const sessionSvc = yield* SessionNs.Service
|
||||
const type = unique("remote-history-fail")
|
||||
const info = workspaceInfo(Instance.project.id, type)
|
||||
@@ -1043,7 +1225,7 @@ describe("workspace-old sync state", () => {
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const workspace = yield* Workspace.Service
|
||||
const sessionSvc = yield* SessionNs.Service
|
||||
const captured = captureGlobalEvents()
|
||||
try {
|
||||
@@ -1110,7 +1292,7 @@ describe("workspace-old sync state", () => {
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const workspace = yield* Workspace.Service
|
||||
const sessionSvc = yield* SessionNs.Service
|
||||
const captured = captureGlobalEvents()
|
||||
try {
|
||||
@@ -1191,7 +1373,7 @@ describe("workspace-old sync state", () => {
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const workspace = yield* Workspace.Service
|
||||
const sessionSvc = yield* SessionNs.Service
|
||||
const captured = captureGlobalEvents()
|
||||
try {
|
||||
@@ -1230,7 +1412,7 @@ describe("workspace-old sync state", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("workspace-old waitForSync", () => {
|
||||
describe("workspace waitForSync", () => {
|
||||
test("returns immediately for an empty fence", async () => {
|
||||
await withInstance(async () => {
|
||||
await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_empty"), {})).resolves.toBeUndefined()
|
||||
|
||||
@@ -12,8 +12,14 @@ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
||||
const { Flag } = await import("@opencode-ai/core/flag/flag")
|
||||
const { Plugin } = await import("../../src/plugin/index")
|
||||
const { Workspace } = await import("../../src/control-plane/workspace")
|
||||
const { InstanceBootstrap } = await import("../../src/project/bootstrap")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, Workspace.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
const { InstanceStore } = await import("../../src/project/instance-store")
|
||||
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
|
||||
@@ -200,6 +200,35 @@ describe("Worktree", () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe("list", () => {
|
||||
it.live("uses parent folder name when worktree basename matches the primary worktree", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const parent = path.join(path.dirname(dir), `${path.basename(dir)}-parent`)
|
||||
const target = path.join(parent, path.basename(dir))
|
||||
const branch = `same-basename-list-${Date.now()}`
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(parent, { recursive: true }))
|
||||
yield* Effect.promise(() => $`git worktree add -b ${branch} ${target}`.cwd(dir).quiet())
|
||||
|
||||
const list = yield* svc.list()
|
||||
const directory = yield* Effect.promise(() => fs.realpath(target).catch(() => target))
|
||||
|
||||
expect(list).toContainEqual({
|
||||
name: path.basename(parent),
|
||||
branch,
|
||||
directory,
|
||||
})
|
||||
|
||||
yield* svc.remove({ directory: target })
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
describe("remove edge cases", () => {
|
||||
it.live("remove non-existent directory succeeds silently", () =>
|
||||
provideTmpdirInstance(
|
||||
|
||||
@@ -10,8 +10,10 @@ import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
|
||||
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
@@ -36,6 +38,11 @@ const testStateLayer = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
|
||||
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
testStateLayer,
|
||||
@@ -43,7 +50,7 @@ const it = testEffect(
|
||||
NodeServices.layer,
|
||||
InstanceLayer.layer,
|
||||
Project.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
workspaceLayer,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
@@ -9,6 +9,8 @@ import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
@@ -30,6 +32,10 @@ void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||
)
|
||||
|
||||
function app(experimental = true) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||
@@ -106,7 +112,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
|
||||
extra: null,
|
||||
projectID: input.projectID,
|
||||
}),
|
||||
).pipe(Effect.provide(Workspace.defaultLayer))
|
||||
).pipe(Effect.provide(workspaceLayer))
|
||||
})
|
||||
|
||||
function request(path: string, init?: RequestInit) {
|
||||
|
||||
@@ -20,6 +20,8 @@ import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import {
|
||||
@@ -45,13 +47,18 @@ const testStateLayer = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
|
||||
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
testStateLayer,
|
||||
NodeHttpServer.layerTest,
|
||||
NodeServices.layer,
|
||||
Project.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
workspaceLayer,
|
||||
Socket.layerWebSocketConstructorGlobal,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@ import { Server } from "../../src/server/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import { WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
@@ -23,9 +25,11 @@ void Log.init({ print: false })
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer),
|
||||
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer))
|
||||
|
||||
function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) {
|
||||
return Effect.promise(() => {
|
||||
@@ -60,6 +64,36 @@ function localAdapter(directory: string): WorkspaceAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
function listedAdapter(directory: string, type: string): WorkspaceAdapter {
|
||||
return {
|
||||
name: "Listed Test",
|
||||
description: "List a local test workspace",
|
||||
configure(info) {
|
||||
return { ...info, name: "unused", directory }
|
||||
},
|
||||
async create() {},
|
||||
async remove() {},
|
||||
list() {
|
||||
return [
|
||||
{
|
||||
type,
|
||||
name: "listed-test",
|
||||
branch: "listed/main",
|
||||
directory,
|
||||
extra: { listed: true },
|
||||
projectID: Instance.project.id,
|
||||
},
|
||||
]
|
||||
},
|
||||
target() {
|
||||
return {
|
||||
type: "local" as const,
|
||||
directory,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
|
||||
return {
|
||||
name: "Remote Test",
|
||||
@@ -192,6 +226,30 @@ describe("workspace HttpApi", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("serves list sync endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
const type = `listed-${Math.random().toString(36).slice(2)}`
|
||||
registerAdapter(project.project.id, type, listedAdapter(path.join(dir, ".listed"), type))
|
||||
|
||||
const response = yield* request(WorkspacePaths.syncList, dir, { method: "POST" })
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
const listed = yield* request(WorkspacePaths.list, dir)
|
||||
expect(yield* Effect.promise(() => listed.json())).toMatchObject([
|
||||
{
|
||||
type,
|
||||
name: "listed-test",
|
||||
branch: "listed/main",
|
||||
directory: path.join(dir, ".listed"),
|
||||
extra: { listed: true },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates workspace with the TUI payload shape", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
ExperimentalWorkspaceRemoveErrors,
|
||||
ExperimentalWorkspaceRemoveResponses,
|
||||
ExperimentalWorkspaceStatusResponses,
|
||||
ExperimentalWorkspaceSyncListResponses,
|
||||
ExperimentalWorkspaceWarpErrors,
|
||||
ExperimentalWorkspaceWarpResponses,
|
||||
FileListResponses,
|
||||
@@ -202,8 +203,12 @@ import type {
|
||||
V2SessionMessagesResponses,
|
||||
V2SessionPromptResponses,
|
||||
V2SessionWaitResponses,
|
||||
VcsApplyErrors,
|
||||
VcsApplyResponses,
|
||||
VcsDiffRawResponses,
|
||||
VcsDiffResponses,
|
||||
VcsGetResponses,
|
||||
VcsStatusResponses,
|
||||
WorktreeCreateErrors,
|
||||
WorktreeCreateInput,
|
||||
WorktreeCreateResponses,
|
||||
@@ -945,6 +950,36 @@ export class Workspace extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync workspace list
|
||||
*
|
||||
* Register missing workspaces returned by workspace adapters.
|
||||
*/
|
||||
public syncList<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<ExperimentalWorkspaceSyncListResponses, unknown, ThrowOnError>({
|
||||
url: "/experimental/workspace/sync-list",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace status
|
||||
*
|
||||
@@ -1022,6 +1057,7 @@ export class Workspace extends HeyApiClient {
|
||||
workspace?: string
|
||||
id?: string | null
|
||||
sessionID?: string
|
||||
copyChanges?: boolean
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
@@ -1034,6 +1070,7 @@ export class Workspace extends HeyApiClient {
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "id" },
|
||||
{ in: "body", key: "sessionID" },
|
||||
{ in: "body", key: "copyChanges" },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -1555,6 +1592,38 @@ export class Path extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Diff extends HeyApiClient {
|
||||
/**
|
||||
* Get raw VCS diff
|
||||
*
|
||||
* Retrieve a raw patch for current uncommitted changes.
|
||||
*/
|
||||
public raw<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<VcsDiffRawResponses, unknown, ThrowOnError>({
|
||||
url: "/vcs/diff/raw",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Vcs extends HeyApiClient {
|
||||
/**
|
||||
* Get VCS info
|
||||
@@ -1586,6 +1655,36 @@ export class Vcs extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VCS status
|
||||
*
|
||||
* Retrieve changed files in the current working tree without patches.
|
||||
*/
|
||||
public status<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<VcsStatusResponses, unknown, ThrowOnError>({
|
||||
url: "/vcs/status",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VCS diff
|
||||
*
|
||||
@@ -1617,6 +1716,48 @@ export class Vcs extends HeyApiClient {
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply VCS patch
|
||||
*
|
||||
* Apply a raw patch to the current working tree.
|
||||
*/
|
||||
public apply<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
patch?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "patch" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<VcsApplyResponses, VcsApplyErrors, ThrowOnError>({
|
||||
url: "/vcs/apply",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private _diff?: Diff
|
||||
get diff2(): Diff {
|
||||
return (this._diff ??= new Diff({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class Command extends HeyApiClient {
|
||||
|
||||
@@ -1474,6 +1474,13 @@ export type VcsInfo = {
|
||||
default_branch?: string
|
||||
}
|
||||
|
||||
export type VcsFileStatus = {
|
||||
file: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type VcsFileDiff = {
|
||||
file: string
|
||||
patch: string
|
||||
@@ -1482,6 +1489,14 @@ export type VcsFileDiff = {
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type VcsApplyError = {
|
||||
name: "VcsApplyError"
|
||||
data: {
|
||||
message: string
|
||||
reason: "non-git" | "not-clean"
|
||||
}
|
||||
}
|
||||
|
||||
export type Command = {
|
||||
name: string
|
||||
description?: string
|
||||
@@ -1734,6 +1749,14 @@ export type Workspace = {
|
||||
directory: string | null
|
||||
extra: unknown | null
|
||||
projectID: string
|
||||
timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||
}
|
||||
|
||||
export type WorkspaceWarpError = {
|
||||
name: "WorkspaceWarpError"
|
||||
data: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventMessageUpdated = {
|
||||
@@ -4020,6 +4043,25 @@ export type VcsGetResponses = {
|
||||
|
||||
export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
|
||||
|
||||
export type VcsStatusData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/vcs/status"
|
||||
}
|
||||
|
||||
export type VcsStatusResponses = {
|
||||
/**
|
||||
* VCS status
|
||||
*/
|
||||
200: Array<VcsFileStatus>
|
||||
}
|
||||
|
||||
export type VcsStatusResponse = VcsStatusResponses[keyof VcsStatusResponses]
|
||||
|
||||
export type VcsDiffData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -4040,6 +4082,57 @@ export type VcsDiffResponses = {
|
||||
|
||||
export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses]
|
||||
|
||||
export type VcsDiffRawData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/vcs/diff/raw"
|
||||
}
|
||||
|
||||
export type VcsDiffRawResponses = {
|
||||
/**
|
||||
* Raw VCS diff
|
||||
*/
|
||||
200: string
|
||||
}
|
||||
|
||||
export type VcsDiffRawResponse = VcsDiffRawResponses[keyof VcsDiffRawResponses]
|
||||
|
||||
export type VcsApplyData = {
|
||||
body?: {
|
||||
patch: string
|
||||
}
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/vcs/apply"
|
||||
}
|
||||
|
||||
export type VcsApplyErrors = {
|
||||
/**
|
||||
* VcsApplyError
|
||||
*/
|
||||
400: VcsApplyError
|
||||
}
|
||||
|
||||
export type VcsApplyError2 = VcsApplyErrors[keyof VcsApplyErrors]
|
||||
|
||||
export type VcsApplyResponses = {
|
||||
/**
|
||||
* VCS patch applied
|
||||
*/
|
||||
200: {
|
||||
applied: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type VcsApplyResponse = VcsApplyResponses[keyof VcsApplyResponses]
|
||||
|
||||
export type CommandListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -6608,6 +6701,26 @@ export type ExperimentalWorkspaceCreateResponses = {
|
||||
export type ExperimentalWorkspaceCreateResponse =
|
||||
ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses]
|
||||
|
||||
export type ExperimentalWorkspaceSyncListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/experimental/workspace/sync-list"
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceSyncListResponses = {
|
||||
/**
|
||||
* Workspace list synced
|
||||
*/
|
||||
204: void
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceSyncListResponse =
|
||||
ExperimentalWorkspaceSyncListResponses[keyof ExperimentalWorkspaceSyncListResponses]
|
||||
|
||||
export type ExperimentalWorkspaceStatusData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -6667,6 +6780,7 @@ export type ExperimentalWorkspaceWarpData = {
|
||||
body?: {
|
||||
id: string | null
|
||||
sessionID: string
|
||||
copyChanges?: boolean
|
||||
}
|
||||
path?: never
|
||||
query?: {
|
||||
@@ -6678,9 +6792,9 @@ export type ExperimentalWorkspaceWarpData = {
|
||||
|
||||
export type ExperimentalWorkspaceWarpErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
* WorkspaceWarpError | VcsApplyError
|
||||
*/
|
||||
400: BadRequestError
|
||||
400: WorkspaceWarpError | VcsApplyError
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors]
|
||||
|
||||
+311
-3
@@ -1897,6 +1897,54 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/vcs/status": {
|
||||
"get": {
|
||||
"tags": ["instance"],
|
||||
"operationId": "vcs.status",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "VCS status",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/VcsFileStatus"
|
||||
},
|
||||
"description": "VCS status"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Retrieve changed files in the current working tree without patches.",
|
||||
"summary": "Get VCS status",
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.status({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/vcs/diff": {
|
||||
"get": {
|
||||
"tags": ["instance"],
|
||||
@@ -1954,6 +2002,128 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/vcs/diff/raw": {
|
||||
"get": {
|
||||
"tags": ["instance"],
|
||||
"operationId": "vcs.diff.raw",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Raw VCS diff",
|
||||
"content": {
|
||||
"text/x-diff; charset=utf-8": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Retrieve a raw patch for current uncommitted changes.",
|
||||
"summary": "Get raw VCS diff",
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff.raw({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/vcs/apply": {
|
||||
"post": {
|
||||
"tags": ["instance"],
|
||||
"operationId": "vcs.apply",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "VCS patch applied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"applied": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["applied"],
|
||||
"additionalProperties": false,
|
||||
"description": "VCS patch applied"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "VcsApplyError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VcsApplyError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Apply a raw patch to the current working tree.",
|
||||
"summary": "Apply VCS patch",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"patch": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["patch"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.apply({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/command": {
|
||||
"get": {
|
||||
"tags": ["instance"],
|
||||
@@ -8247,6 +8417,43 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/experimental/workspace/sync-list": {
|
||||
"post": {
|
||||
"tags": ["workspace"],
|
||||
"operationId": "experimental.workspace.syncList",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Workspace list synced"
|
||||
}
|
||||
},
|
||||
"description": "Register missing workspaces returned by workspace adapters.",
|
||||
"summary": "Sync workspace list",
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.syncList({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/experimental/workspace/status": {
|
||||
"get": {
|
||||
"tags": ["workspace"],
|
||||
@@ -8396,11 +8603,18 @@
|
||||
"description": "Session warped"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"description": "WorkspaceWarpError | VcsApplyError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/WorkspaceWarpError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/VcsApplyError"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8426,6 +8640,9 @@
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
},
|
||||
"copyChanges": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID"],
|
||||
@@ -12665,6 +12882,28 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VcsFileStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"additions": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"deletions": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["added", "deleted", "modified"]
|
||||
}
|
||||
},
|
||||
"required": ["file", "additions", "deletions", "status"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VcsFileDiff": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -12690,6 +12929,31 @@
|
||||
"required": ["file", "patch", "additions", "deletions"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VcsApplyError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"enum": ["VcsApplyError"]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"enum": ["non-git", "not-clean"]
|
||||
}
|
||||
},
|
||||
"required": ["message", "reason"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["name", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Command": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13426,9 +13690,53 @@
|
||||
},
|
||||
"projectID": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeUsed": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["NaN"]
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["Infinity"]
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["-Infinity"]
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["Infinity", "-Infinity", "NaN"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "name", "branch", "directory", "extra", "projectID"],
|
||||
"required": ["id", "type", "name", "branch", "directory", "extra", "projectID", "timeUsed"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"WorkspaceWarpError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"enum": ["WorkspaceWarpError"]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["message"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["name", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncEventMessageUpdated": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -128,8 +128,12 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
|
||||
}
|
||||
: {
|
||||
oldFile: local.before,
|
||||
newFile: local.after,
|
||||
oldFile: local.before
|
||||
? { ...local.before, contents: typeof local.before.contents === "string" ? local.before.contents : "" }
|
||||
: local.before,
|
||||
newFile: local.after
|
||||
? { ...local.after, contents: typeof local.after.contents === "string" ? local.after.contents : "" }
|
||||
: local.after,
|
||||
lineAnnotations: annotations,
|
||||
fileContainer: fileDiffRef,
|
||||
containerWrapper: container,
|
||||
|
||||
@@ -1906,11 +1906,11 @@ ToolRegistry.register({
|
||||
mode="diff"
|
||||
before={{
|
||||
name: props.metadata?.filediff?.file || props.input.filePath,
|
||||
contents: props.metadata?.filediff?.before || props.input.oldString,
|
||||
contents: props.metadata?.filediff?.before || props.input.oldString || "",
|
||||
}}
|
||||
after={{
|
||||
name: props.metadata?.filediff?.file || props.input.filePath,
|
||||
contents: props.metadata?.filediff?.after || props.input.newString,
|
||||
contents: props.metadata?.filediff?.after || props.input.newString || "",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.41",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -165,7 +165,7 @@ https://opencode.ai/zen/v1/models
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
قد تلاحظ _Claude Haiku 3.5_ في سجل الاستخدام. هذا [نموذج منخفض التكلفة](/docs/config/#models) يُستخدم لتوليد عناوين جلساتك.
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
Možda ćete primijetiti _Claude Haiku 3.5_ u historiji korištenja. To je [low cost model](/docs/config/#models) koji se koristi za generisanje naslova vaših sesija.
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**.
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
Du vil måske bemærke _Claude Haiku 3.5_ i din brugshistorik. Det er en [lavprismodel](/docs/config/#models), som bruges til at generere titlerne på dine sessioner.
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
Möglicherweise siehst du _Claude Haiku 3.5_ in deinem Nutzungsverlauf. Das ist ein [kostengünstiges Modell](/docs/config/#models), das verwendet wird, um die Titel deiner Sessions zu generieren.
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
|
||||
| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode |
|
||||
| [opencode-sentry-monitor](https://github.com/stolinski/opencode-sentry-monitor) | Trace and debug your AI agents with Sentry AI Monitoring |
|
||||
| [opencode-firecrawl](https://github.com/firecrawl/opencode-firecrawl) | Web scraping, crawling, and search via the Firecrawl CLI |
|
||||
| [opencode-jfrog-plugin](https://github.com/jfrog/opencode-jfrog-plugin) | JFrog Plugin for seamless integration of Opencode users to JFrog platform |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
Puede que notes _Claude Haiku 3.5_ en tu historial de uso. Este es un [modelo de bajo costo](/docs/config/#models) que se usa para generar los títulos de tus sesiones.
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
Vous remarquerez peut-être _Claude Haiku 3.5_ dans votre historique d'utilisation. Il s'agit d'un [modèle à faible coût](/docs/config/#models) utilisé pour générer les titres de vos sessions.
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**.
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
Potresti notare _Claude Haiku 3.5_ nella cronologia di utilizzo. È un [modello a basso costo](/docs/config/#models) usato per generare i titoli delle tue sessioni.
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ https://opencode.ai/zen/v1/models
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
使用履歴に _Claude Haiku 3.5_ が表示されることがあります。これはセッションのタイトル生成に使われる [low cost model](/docs/config/#models) です。
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ https://opencode.ai/zen/v1/models
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
사용 기록에서 *Claude Haiku 3.5*를 볼 수 있습니다. 이는 세션 제목을 생성할 때 사용되는 [저비용 모델](/docs/config/#models)입니다.
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**.
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
Du vil kanskje legge merke til _Claude Haiku 3.5_ i brukshistorikken din. Dette er en [lavprismodell](/docs/config/#models) som brukes til å generere titlene på øktene dine.
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów*
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
Możesz zauważyć _Claude Haiku 3.5_ w historii użycia. To [niedrogi model](/docs/config/#models),
|
||||
który służy do generowania tytułów Twoich sesji.
|
||||
|
||||
@@ -161,7 +161,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**.
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
Você pode notar _Claude Haiku 3.5_ no seu histórico de uso. Este é um [low cost model](/docs/config/#models) usado para gerar os títulos das suas sessões.
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ https://opencode.ai/zen/v1/models
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
Вы можете заметить _Claude Haiku 3.5_ в истории использования. Это [недорогая модель](/docs/config/#models), которая используется для генерации заголовков ваших сессий.
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ https://opencode.ai/zen/v1/models
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
คุณอาจสังเกตเห็น _Claude Haiku 3.5_ ในประวัติการใช้งานของคุณ นี่คือ [low cost model](/docs/config/#models) ที่ใช้สร้างชื่อ session ของคุณ
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user