fix(core): add historical sync on workspace connect (#23121)
This commit is contained in:
@@ -2,6 +2,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
|||||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
import { batch, onCleanup, onMount } from "solid-js"
|
import { batch, onCleanup, onMount } from "solid-js"
|
||||||
|
|
||||||
export type EventSource = {
|
export type EventSource = {
|
||||||
@@ -39,6 +40,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
let queue: GlobalEvent[] = []
|
let queue: GlobalEvent[] = []
|
||||||
let timer: Timer | undefined
|
let timer: Timer | undefined
|
||||||
let last = 0
|
let last = 0
|
||||||
|
const retryDelay = 1000
|
||||||
|
const maxRetryDelay = 30000
|
||||||
|
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
if (queue.length === 0) return
|
if (queue.length === 0) return
|
||||||
@@ -73,9 +76,20 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
sse = ctrl
|
sse = ctrl
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
let attempt = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||||
const events = await sdk.global.event({ signal: ctrl.signal })
|
|
||||||
|
const events = await sdk.global.event({
|
||||||
|
signal: ctrl.signal,
|
||||||
|
sseMaxRetryAttempts: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||||
|
// Start syncing workspaces, it's important to do this after
|
||||||
|
// we've started listening to events
|
||||||
|
await sdk.sync.start().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
if (ctrl.signal.aborted) break
|
if (ctrl.signal.aborted) break
|
||||||
@@ -84,6 +98,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
|
|
||||||
if (timer) clearTimeout(timer)
|
if (timer) clearTimeout(timer)
|
||||||
if (queue.length > 0) flush()
|
if (queue.length > 0) flush()
|
||||||
|
attempt += 1
|
||||||
|
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||||
|
|
||||||
|
// Exponential backoff
|
||||||
|
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, backoff))
|
||||||
}
|
}
|
||||||
})().catch(() => {})
|
})().catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -92,6 +112,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
if (props.events) {
|
if (props.events) {
|
||||||
const unsub = await props.events.subscribe(handleEvent)
|
const unsub = await props.events.subscribe(handleEvent)
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|
||||||
|
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||||
|
// Start syncing workspaces, it's important to do this after
|
||||||
|
// we've started listening to events
|
||||||
|
await sdk.sync.start().catch(() => {})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
startSSE()
|
startSSE()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { BusEvent } from "@/bus/bus-event"
|
|||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
import { SyncEvent } from "@/sync"
|
import { SyncEvent } from "@/sync"
|
||||||
import { EventTable } from "@/sync/event.sql"
|
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Log } from "@/util"
|
import { Log } from "@/util"
|
||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
@@ -23,8 +23,8 @@ import { SessionTable } from "@/session/session.sql"
|
|||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
import { errorData } from "@/util/error"
|
import { errorData } from "@/util/error"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { EventSequenceTable } from "@/sync/event.sql"
|
|
||||||
import { waitEvent } from "./util"
|
import { waitEvent } from "./util"
|
||||||
|
import { WorkspaceContext } from "./workspace-context"
|
||||||
|
|
||||||
export const Info = WorkspaceInfo.meta({
|
export const Info = WorkspaceInfo.meta({
|
||||||
ref: "Workspace",
|
ref: "Workspace",
|
||||||
@@ -297,22 +297,13 @@ export function list(project: Project.Info) {
|
|||||||
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
|
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
|
||||||
)
|
)
|
||||||
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
|
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
|
||||||
for (const space of spaces) startSync(space)
|
|
||||||
return spaces
|
return spaces
|
||||||
}
|
}
|
||||||
|
|
||||||
function lookup(id: WorkspaceID) {
|
export const get = fn(WorkspaceID.zod, async (id) => {
|
||||||
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
||||||
if (!row) return
|
if (!row) return
|
||||||
return fromRow(row)
|
return fromRow(row)
|
||||||
}
|
|
||||||
|
|
||||||
export const get = fn(WorkspaceID.zod, async (id) => {
|
|
||||||
const space = lookup(id)
|
|
||||||
if (!space) return
|
|
||||||
startSync(space)
|
|
||||||
return space
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const remove = fn(WorkspaceID.zod, async (id) => {
|
export const remove = fn(WorkspaceID.zod, async (id) => {
|
||||||
@@ -437,6 +428,70 @@ async function connectSSE(url: URL | string, headers: HeadersInit | undefined, s
|
|||||||
return res.body
|
return res.body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncHistory(space: Info, url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) {
|
||||||
|
const sessionIDs = Database.use((db) =>
|
||||||
|
db
|
||||||
|
.select({ id: SessionTable.id })
|
||||||
|
.from(SessionTable)
|
||||||
|
.where(eq(SessionTable.workspace_id, space.id))
|
||||||
|
.all()
|
||||||
|
.map((row) => row.id),
|
||||||
|
)
|
||||||
|
const state = sessionIDs.length
|
||||||
|
? Object.fromEntries(
|
||||||
|
Database.use((db) =>
|
||||||
|
db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(),
|
||||||
|
).map((row) => [row.aggregate_id, row.seq]),
|
||||||
|
)
|
||||||
|
: {}
|
||||||
|
|
||||||
|
log.info("syncing workspace history", {
|
||||||
|
workspaceID: space.id,
|
||||||
|
sessions: sessionIDs.length,
|
||||||
|
known: Object.keys(state).length,
|
||||||
|
})
|
||||||
|
|
||||||
|
const requestHeaders = new Headers(headers)
|
||||||
|
requestHeaders.set("content-type", "application/json")
|
||||||
|
|
||||||
|
const res = await fetch(route(url, "/sync/history"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: JSON.stringify(state),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text()
|
||||||
|
throw new Error(`Workspace history HTTP failure: ${res.status} ${body}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await res.json()
|
||||||
|
|
||||||
|
return WorkspaceContext.provide({
|
||||||
|
workspaceID: space.id,
|
||||||
|
fn: () => {
|
||||||
|
for (const event of events) {
|
||||||
|
SyncEvent.replay(
|
||||||
|
{
|
||||||
|
id: event.id,
|
||||||
|
aggregateID: event.aggregate_id,
|
||||||
|
seq: event.seq,
|
||||||
|
type: event.type,
|
||||||
|
data: event.data,
|
||||||
|
},
|
||||||
|
{ publish: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info("workspace history synced", {
|
||||||
|
workspaceID: space.id,
|
||||||
|
events: events.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function syncWorkspaceLoop(space: Info, signal: AbortSignal) {
|
async function syncWorkspaceLoop(space: Info, signal: AbortSignal) {
|
||||||
const adaptor = await getAdaptor(space.projectID, space.type)
|
const adaptor = await getAdaptor(space.projectID, space.type)
|
||||||
const target = await adaptor.target(space)
|
const target = await adaptor.target(space)
|
||||||
@@ -452,7 +507,9 @@ async function syncWorkspaceLoop(space: Info, signal: AbortSignal) {
|
|||||||
let stream
|
let stream
|
||||||
try {
|
try {
|
||||||
stream = await connectSSE(target.url, target.headers, signal)
|
stream = await connectSSE(target.url, target.headers, signal)
|
||||||
|
await syncHistory(space, target.url, target.headers, signal)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
stream = null
|
||||||
setStatus(space.id, "error")
|
setStatus(space.id, "error")
|
||||||
log.info("failed to connect to global sync", {
|
log.info("failed to connect to global sync", {
|
||||||
workspace: space.name,
|
workspace: space.name,
|
||||||
@@ -469,6 +526,7 @@ async function syncWorkspaceLoop(space: Info, signal: AbortSignal) {
|
|||||||
await parseSSE(stream, signal, (evt: any) => {
|
await parseSSE(stream, signal, (evt: any) => {
|
||||||
try {
|
try {
|
||||||
if (!("payload" in evt)) return
|
if (!("payload" in evt)) return
|
||||||
|
if (evt.payload.type === "server.heartbeat") return
|
||||||
|
|
||||||
if (evt.payload.type === "sync") {
|
if (evt.payload.type === "sync") {
|
||||||
SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent)
|
SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent)
|
||||||
@@ -536,4 +594,19 @@ function stopSync(id: WorkspaceID) {
|
|||||||
connections.delete(id)
|
connections.delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function startWorkspaceSyncing(projectID: ProjectID) {
|
||||||
|
const spaces = Database.use((db) =>
|
||||||
|
db
|
||||||
|
.select({ workspace: WorkspaceTable })
|
||||||
|
.from(WorkspaceTable)
|
||||||
|
.innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id))
|
||||||
|
.where(eq(WorkspaceTable.project_id, projectID))
|
||||||
|
.all(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const row of new Map(spaces.map((row) => [row.workspace.id, row.workspace])).values()) {
|
||||||
|
void startSync(fromRow(row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export * as Workspace from "./workspace"
|
export * as Workspace from "./workspace"
|
||||||
|
|||||||
@@ -130,13 +130,6 @@ export async function http(url: string | URL, extra: HeadersInit | undefined, re
|
|||||||
const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve()
|
const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve()
|
||||||
|
|
||||||
return done.then(async () => {
|
return done.then(async () => {
|
||||||
console.log("proxy http response", {
|
|
||||||
method: req.method,
|
|
||||||
request: req.url,
|
|
||||||
url: String(url),
|
|
||||||
status: res.status,
|
|
||||||
statusText: res.statusText,
|
|
||||||
})
|
|
||||||
return new Response(res.body, {
|
return new Response(res.body, {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
statusText: res.statusText,
|
statusText: res.statusText,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Database, asc, and, not, or, lte, eq } from "@/storage"
|
|||||||
import { EventTable } from "@/sync/event.sql"
|
import { EventTable } from "@/sync/event.sql"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { Log } from "@/util"
|
import { Log } from "@/util"
|
||||||
|
import { startWorkspaceSyncing } from "@/control-plane/workspace"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
import { errors } from "../../error"
|
import { errors } from "../../error"
|
||||||
|
|
||||||
const ReplayEvent = z.object({
|
const ReplayEvent = z.object({
|
||||||
@@ -20,6 +22,28 @@ const log = Log.create({ service: "server.sync" })
|
|||||||
|
|
||||||
export const SyncRoutes = lazy(() =>
|
export const SyncRoutes = lazy(() =>
|
||||||
new Hono()
|
new Hono()
|
||||||
|
.post(
|
||||||
|
"/start",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Start workspace sync",
|
||||||
|
description: "Start sync loops for workspaces in the current project that have active sessions.",
|
||||||
|
operationId: "sync.start",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Workspace sync started",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
startWorkspaceSyncing(Instance.project.id)
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
.post(
|
.post(
|
||||||
"/replay",
|
"/replay",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -75,7 +99,7 @@ export const SyncRoutes = lazy(() =>
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.post(
|
||||||
"/history",
|
"/history",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "List sync events",
|
summary: "List sync events",
|
||||||
|
|||||||
@@ -141,9 +141,12 @@ describe("Workspace.sessionRestore", () => {
|
|||||||
Object.assign(
|
Object.assign(
|
||||||
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
|
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
|
||||||
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url)
|
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url)
|
||||||
if (url.pathname !== "/base/sync/replay") {
|
if (url.pathname === "/base/global/event") {
|
||||||
return eventStreamResponse()
|
return eventStreamResponse()
|
||||||
}
|
}
|
||||||
|
if (url.pathname === "/base/sync/history") {
|
||||||
|
return Response.json([])
|
||||||
|
}
|
||||||
const body = JSON.parse(String(init?.body))
|
const body = JSON.parse(String(init?.body))
|
||||||
posts.push({
|
posts.push({
|
||||||
path: url.pathname,
|
path: url.pathname,
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ import type {
|
|||||||
SyncHistoryListResponses,
|
SyncHistoryListResponses,
|
||||||
SyncReplayErrors,
|
SyncReplayErrors,
|
||||||
SyncReplayResponses,
|
SyncReplayResponses,
|
||||||
|
SyncStartResponses,
|
||||||
TextPartInput,
|
TextPartInput,
|
||||||
ToolIdsErrors,
|
ToolIdsErrors,
|
||||||
ToolIdsResponses,
|
ToolIdsResponses,
|
||||||
@@ -3038,7 +3039,7 @@ export class History extends HeyApiClient {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).get<SyncHistoryListResponses, SyncHistoryListErrors, ThrowOnError>({
|
return (options?.client ?? this.client).post<SyncHistoryListResponses, SyncHistoryListErrors, ThrowOnError>({
|
||||||
url: "/sync/history",
|
url: "/sync/history",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...params,
|
||||||
@@ -3052,6 +3053,36 @@ export class History extends HeyApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Sync extends HeyApiClient {
|
export class Sync extends HeyApiClient {
|
||||||
|
/**
|
||||||
|
* Start workspace sync
|
||||||
|
*
|
||||||
|
* Start sync loops for workspaces in the current project that have active sessions.
|
||||||
|
*/
|
||||||
|
public start<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<SyncStartResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/sync/start",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replay sync events
|
* Replay sync events
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4502,6 +4502,25 @@ export type ProviderOauthCallbackResponses = {
|
|||||||
|
|
||||||
export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]
|
export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]
|
||||||
|
|
||||||
|
export type SyncStartData = {
|
||||||
|
body?: never
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
|
url: "/sync/start"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncStartResponses = {
|
||||||
|
/**
|
||||||
|
* Workspace sync started
|
||||||
|
*/
|
||||||
|
200: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncStartResponse = SyncStartResponses[keyof SyncStartResponses]
|
||||||
|
|
||||||
export type SyncReplayData = {
|
export type SyncReplayData = {
|
||||||
body?: {
|
body?: {
|
||||||
directory: string
|
directory: string
|
||||||
|
|||||||
@@ -5224,6 +5224,47 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/sync/start": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "sync.start",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "directory",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "workspace",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Start workspace sync",
|
||||||
|
"description": "Start sync loops for workspaces in the current project that have active sessions.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Workspace sync started",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-codeSamples": [
|
||||||
|
{
|
||||||
|
"lang": "js",
|
||||||
|
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.start({\n ...\n})"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/sync/replay": {
|
"/sync/replay": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "sync.replay",
|
"operationId": "sync.replay",
|
||||||
@@ -5328,7 +5369,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/sync/history": {
|
"/sync/history": {
|
||||||
"get": {
|
"post": {
|
||||||
"operationId": "sync.history.list",
|
"operationId": "sync.history.list",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user