Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { isMedia } from "@/util/media"
import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { Effect, Schema } from "effect"
import { limitSummaryDiffs } from "./summary-diffs"

export const node = LayerNode.group([Database.node])

Expand Down Expand Up @@ -79,12 +80,16 @@ export const cursor = {
},
}

const info = (row: typeof MessageTable.$inferSelect) =>
({
const info = (row: typeof MessageTable.$inferSelect) => {
const summary = row.data.summary
const diffs = summary?.diffs ? limitSummaryDiffs(summary.diffs) : undefined
return {
...row.data,
...(summary ? { summary: diffs ? { ...summary, diffs } : summary } : {}),
id: row.id,
sessionID: row.session_id,
}) as Info
} as Info
}

const part = (row: typeof PartTable.$inferSelect) =>
({
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { SessionMessageID } from "@opencode-ai/schema/session-message-id"
import { limitSummaryDiffs } from "./summary-diffs"

const runtime = makeRuntime(Database.Service, Database.defaultLayer)

Expand All @@ -65,7 +66,7 @@ export function fromRow(row: SessionRow): Info {
additions: row.summary_additions ?? 0,
deletions: row.summary_deletions ?? 0,
files: row.summary_files ?? 0,
diffs: row.summary_diffs ?? undefined,
diffs: row.summary_diffs ? limitSummaryDiffs(row.summary_diffs) : undefined,
}
: undefined
const share = row.share_url ? { url: row.share_url } : undefined
Expand Down Expand Up @@ -136,7 +137,7 @@ export function toRow(info: Info) {
summary_additions: info.summary?.additions,
summary_deletions: info.summary?.deletions,
summary_files: info.summary?.files,
summary_diffs: info.summary?.diffs,
summary_diffs: info.summary?.diffs ? limitSummaryDiffs(info.summary.diffs) : undefined,
metadata: info.metadata,
cost: info.cost ?? 0,
tokens_input: (info.tokens ?? EmptyTokens).input,
Expand Down
40 changes: 40 additions & 0 deletions packages/opencode/src/session/summary-diffs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export const MAX_SUMMARY_DIFFS = 200
export const MAX_SUMMARY_PATCH_BYTES = 10_000_000
export const MAX_SUMMARY_TOTAL_PATCH_BYTES = 10_000_000

type SummaryDiff = {
readonly patch?: string
}

// Bound both the number of files and the total patch payload so one huge
// workspace diff cannot balloon a stored session/message record.
export function limitSummaryDiffs<T extends SummaryDiff>(diffs: readonly T[]) {
const result: T[] = []
let totalPatchBytes = 0

for (const diff of diffs) {
if (result.length >= MAX_SUMMARY_DIFFS) break

const patch = diff.patch
if (typeof patch !== "string") {
result.push(diff)
continue
}

const patchBytes = Buffer.byteLength(patch)
if (patchBytes > MAX_SUMMARY_PATCH_BYTES) {
result.push({ ...diff, patch: "" })
continue
}

if (totalPatchBytes + patchBytes > MAX_SUMMARY_TOTAL_PATCH_BYTES) {
result.push({ ...diff, patch: "" })
continue
}

totalPatchBytes += patchBytes
result.push(diff)
}

return result
}
35 changes: 30 additions & 5 deletions packages/opencode/src/session/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import { Snapshot } from "@/snapshot"
import { Session } from "./session"
import { SessionID, MessageID } from "./schema"
import { Config } from "@/config/config"
import {
MAX_SUMMARY_DIFFS,
MAX_SUMMARY_PATCH_BYTES,
MAX_SUMMARY_TOTAL_PATCH_BYTES,
limitSummaryDiffs,
} from "./summary-diffs"

function unquoteGitPath(input: string) {
if (!input.startsWith('"')) return input
Expand Down Expand Up @@ -66,7 +72,10 @@ function unquoteGitPath(input: string) {
export interface Interface {
readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<void>
readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Snapshot.FileDiff[]>
readonly computeDiff: (input: { messages: SessionV1.WithParts[] }) => Effect.Effect<Snapshot.FileDiff[]>
readonly computeDiff: (input: {
messages: SessionV1.WithParts[]
limit?: number
}) => Effect.Effect<Snapshot.FileDiff[]>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/SessionSummary") {}
Expand All @@ -79,7 +88,10 @@ export const layer = Layer.effect(
const events = yield* EventV2Bridge.Service
const config = yield* Config.Service

const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionV1.WithParts[] }) {
const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: {
messages: SessionV1.WithParts[]
limit?: number
}) {
let from: string | undefined
let to: string | undefined
for (const item of input.messages) {
Expand All @@ -95,7 +107,18 @@ export const layer = Layer.effect(
if (part.type === "step-finish" && part.snapshot) to = part.snapshot
}
}
if (from && to) return yield* snapshot.diffFull(from, to)
if (from && to)
return yield* snapshot.diffFull(
from,
to,
input.limit === undefined
? undefined
: {
limit: input.limit,
maxPatchBytes: MAX_SUMMARY_PATCH_BYTES,
maxTotalPatchBytes: MAX_SUMMARY_TOTAL_PATCH_BYTES,
},
)
return []
})

Expand All @@ -121,7 +144,9 @@ export const layer = Layer.effect(
)
const target = messages.find((m) => m.info.id === input.messageID)
if (!target || target.info.role !== "user") return
const msgDiffs = yield* computeDiff({ messages })
const msgDiffs = limitSummaryDiffs(
yield* computeDiff({ messages, limit: MAX_SUMMARY_DIFFS }),
)
target.info.summary = { ...target.info.summary, diffs: msgDiffs }
yield* sessions.updateMessage(target.info)
})
Expand All @@ -132,7 +157,7 @@ export const layer = Layer.effect(
(item) => item.info.id === input.messageID,
)
if (!message || message.info.role !== "user") return []
const diffs = message.info.summary?.diffs ?? []
const diffs = limitSummaryDiffs(message.info.summary?.diffs ?? [])
return diffs.map((item) => {
if (item.file === undefined) return item
const file = unquoteGitPath(item.file)
Expand Down
92 changes: 67 additions & 25 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,17 @@ interface GitResult {
readonly code: ChildProcessSpawner.ExitCode
readonly text: string
readonly stderr: string
readonly truncated: boolean
}

type State = Omit<Interface, "init">

type DiffOptions = {
readonly limit?: number
readonly maxPatchBytes?: number
readonly maxTotalPatchBytes?: number
}

export interface Interface {
readonly init: () => Effect.Effect<void>
readonly cleanup: () => Effect.Effect<void>
Expand All @@ -41,7 +48,7 @@ export interface Interface {
readonly restore: (snapshot: string) => Effect.Effect<void>
readonly revert: (patches: Patch[]) => Effect.Effect<void>
readonly diff: (hash: string) => Effect.Effect<string>
readonly diffFull: (from: string, to: string) => Effect.Effect<FileDiff[]>
readonly diffFull: (from: string, to: string, options?: DiffOptions) => Effect.Effect<FileDiff[]>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Snapshot") {}
Expand Down Expand Up @@ -79,22 +86,27 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
encodeNulTerminatedPaths(files.map((file) => `:(top,literal)${file}`))

const git = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string>; stdin?: string }) {
function* (
cmd: string[],
opts?: { cwd?: string; env?: Record<string, string>; stdin?: string; maxOutputBytes?: number },
) {
const result = yield* appProcess.run(
ChildProcess.make("git", cmd, { cwd: opts?.cwd, env: opts?.env, extendEnv: true }),
{ stdin: opts?.stdin },
{ stdin: opts?.stdin, maxOutputBytes: opts?.maxOutputBytes },
)
return {
code: ChildProcessSpawner.ExitCode(result.exitCode),
text: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
truncated: result.stdoutTruncated,
} satisfies GitResult
},
Effect.catch((err) =>
Effect.succeed({
code: ChildProcessSpawner.ExitCode(1),
text: "",
stderr: err instanceof Error ? err.message : String(err),
truncated: false,
}),
),
)
Expand Down Expand Up @@ -543,7 +555,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
)
})

const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
const diffFull = Effect.fnUntraced(function* (from: string, to: string, options?: DiffOptions) {
return yield* locked(
Effect.gen(function* () {
type Row = {
Expand All @@ -560,27 +572,28 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
ref: string
}

const maxPatchBytes = options?.maxPatchBytes ?? options?.maxTotalPatchBytes
const maxTotalPatchBytes = options?.maxTotalPatchBytes
const limit = options?.limit ?? Number.POSITIVE_INFINITY

const read = Effect.fnUntraced(function* (ref: string) {
const result = yield* git(
[...cfg, ...args(["show", ref])],
maxPatchBytes === undefined ? undefined : { maxOutputBytes: maxPatchBytes },
)
return result.truncated ? "" : result.text
})

const show = Effect.fnUntraced(function* (row: Row) {
if (row.binary) return ["", ""]
if (row.status === "added") {
return [
"",
yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
]
return ["", yield* read(`${to}:${row.file}`)]
}
if (row.status === "deleted") {
return [
yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(
Effect.map((item) => item.text),
),
"",
]
return [yield* read(`${from}:${row.file}`), ""]
}
return yield* Effect.all(
[
git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
],
[read(`${from}:${row.file}`), read(`${to}:${row.file}`)],
{ concurrency: 2 },
)
})
Expand All @@ -606,9 +619,12 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
cwd: state.directory,
extendEnv: true,
}),
{ stdin: refs.map((item) => item.ref).join("\n") + "\n" },
{
stdin: refs.map((item) => item.ref).join("\n") + "\n",
...(maxTotalPatchBytes === undefined ? {} : { maxOutputBytes: maxTotalPatchBytes }),
},
)
if (batch.exitCode !== 0) {
if (batch.exitCode !== 0 || batch.stdoutTruncated) {
yield* Effect.logInfo(
"git cat-file --batch failed during snapshot diff, falling back to per-file git show",
{
Expand Down Expand Up @@ -683,6 +699,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv

const result: FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
let totalPatchBytes = 0

const statuses = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
Expand Down Expand Up @@ -736,16 +753,41 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))

for (let i = 0; i < rows.length; i += step) {
const run = rows.slice(i, i + step)
for (let i = 0; i < rows.length && result.length < limit; i += step) {
const remaining = limit - result.length
const run = rows.slice(i, i + Math.min(step, remaining))
if (!run.length) break
const text = yield* load(run)

for (const row of run) {
const hit = text?.get(row.file) ?? { before: "", after: "" }
const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row)
const nextPatch = row.binary ? "" : patch(row.file, before, after)
const patchBytes = Buffer.byteLength(nextPatch)
if (maxPatchBytes !== undefined && patchBytes > maxPatchBytes) {
result.push({
file: row.file,
patch: "",
additions: row.additions,
deletions: row.deletions,
status: row.status,
})
continue
}
if (maxTotalPatchBytes !== undefined && totalPatchBytes + patchBytes > maxTotalPatchBytes) {
result.push({
file: row.file,
patch: "",
additions: row.additions,
deletions: row.deletions,
status: row.status,
})
return result
}
totalPatchBytes += patchBytes
result.push({
file: row.file,
patch: row.binary ? "" : patch(row.file, before, after),
patch: nextPatch,
additions: row.additions,
deletions: row.deletions,
status: row.status,
Expand Down Expand Up @@ -791,8 +833,8 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
}),
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string, options?: DiffOptions) {
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to, options))
}),
})
}),
Expand Down
Loading
Loading