feat(core): copy file changes when warping (#26190)

This commit is contained in:
James Long
2026-05-07 10:24:17 -04:00
committed by GitHub
parent b6ff1b18c7
commit 3c4b4d5faf
23 changed files with 955 additions and 28 deletions

View File

@@ -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"
@@ -164,19 +165,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)
},
),
)

View File

@@ -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(

View File

@@ -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,8 +13,19 @@ 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,
@@ -78,7 +90,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",

View File

@@ -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)

View File

@@ -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* () {
@@ -44,8 +46,27 @@ 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

View File

@@ -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({