Skip to content

Commit 4167479

Browse files
See USee U
authored andcommitted
feat(core): add v2 manual compact, drain failure event, and v1 prompt bridge
1 parent 768aae0 commit 4167479

20 files changed

Lines changed: 706 additions & 500 deletions

File tree

MERGE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ fork 与上游改了同一处(常见于 TUI 视觉/UX、core 加固逻辑)
160160
| `packages/server/src/cors.ts` (CORS origin 精确匹配) || 保留精确正则匹配(`^https?:\/\/(localhost|127\.0\.0\.1):(\d+)$`)替代 `startsWith`,防 origin 欺骗(上游若已做可取上游版本) |
161161
| `packages/server/src/auth.ts` (timing-safe 密码比较) || 保留 `crypto.timingSafeEqual` + 长度前置检查,防时序攻击(上游若已做可取上游版本) |
162162
| `packages/core/src/control-plane/workspace.sql.ts` (project_id 索引) || 保留 `index("workspace_project_idx").on(table.project_id)`,加速 workspace 按 project 查询(上游若已做可取上游版本) |
163+
| `packages/core/src/catalog.ts` (`available()` 接受 `api.settings.apiKey`) || 保留:V1 config 迁移把 `options.apiKey` 降为 `api.settings.apiKey` 且注册无连接的 env integration,导致可用性判定与模型解析(`session/runner/model.ts`)不一致、v2 写路径 `ModelUnavailableError`;回归测试 `packages/core/test/catalog.test.ts`(derives availability from api settings apiKey);配套 fork 新增影子对比 harness `packages/opencode/test/server/session-shadow.test.ts`(v1/v2 写路径 parity)(上游若已做可取上游版本) |
164+
| `packages/opencode/src/session/prompt.ts` (v1 prompt 桥接 v2 读投影) || 保留:`prompt()``experimentalEventSystem` 下发布 `session.next.prompted` + 每个 synthetic 文本部分一条 `session.next.synthetic`,使 v1 会话对 `SessionV2.messages``session_message` 投影)可见;实现的是上游被跳过测试 `test/session/prompt.test.ts`「prompt emits v2 prompted and synthetic events」定义的合同(已解除 skip),并同步改写「legacy prompt ... without session.next events」为桥接后合同(上游若实现同等桥接则取上游版本并还原测试) |
165+
| `packages/schema/src/session-event.ts` + `packages/core/src/session/runner/llm.ts` (v2 drain 失败 live 事件 `session.next.failed`) || 保留:新增 live-only `SessionEvent.Failed`(不入 durable manifest/不投影),`SessionRunner.run` 失败(非纯中断 cause)时发布,覆盖 step 开始前的失败(如 `ModelUnavailableError`)——此前 drain 失败仅 `execution/local.ts` logError,客户端不可见;step 内失败仍由 durable `Step.Failed` 结算(会双发,与 v1 `session.error` + assistant error 双通道一致);测试 `packages/core/test/session-runner.test.ts`(publishes a live failed event when a drain fails);连带更新 manifest 计数测试(schema 侧原本在 HEAD 即过期失败,修正为真实计数 89/59/35)+ SDK v2 重新生成(上游 runner 若补 durable 状态事件则取上游版本并移除本事件) |
166+
| `packages/tui/src/context/sdk.tsx` + `packages/sdk/js/src/v2/gen` (移除残留 `/sync/*` SDK 面) || 保留:fork 早前裁剪了 server `/sync/*` 路由,但 SDK gen 与 TUI `sdk.sync.start()`(实验 flag 下静默 404)未同步;本次 SDK 重新生成后一并删除 TUI 调用点(上游 sync 面回归时随上游恢复) |
167+
| `packages/core/src/{session.ts,session/compaction.ts,session/history.ts,session/runner/*}` (v2 手动 compact 实现) | 中 | 保留:实现 `SessionV2.compact`(原 stub 恒 `OperationUnavailableError`),spec `specs/v2/session.md:105` 已列为 sanctioned improvement。`SessionCompaction.make` 抽出共享 `summarize` 核心(`reason: "auto"\|"manual"`),新增 `compactManually`(输出预算取 `model.route.defaults.limits?.output`,可选 `instructions` 折入 `buildPrompt`);`SessionHistory.load` 改由新导出 `entries()`(读 epoch `baseline_seq` 只读,不触发 SystemContext 初始化)派生;`SessionRunner` 接口新增 `compact`,`SessionV2.compact` 经 `locations.get(session.location)` 路由,失败/无可压缩内容统一映射 `OperationUnavailableError`(HTTP 503 文案不变)。已知限制:手动 compact 不经 `SessionRunCoordinator` 串行化,与进行中 drain 并发时下一 turn 才生效;测试 `packages/core/test/session-runner.test.ts`(manually compacts on demand / returns false when nothing to compact)(上游实现 v2 手动 compact 后取上游版本) |
163168

164169
### TUI 偏离(四批 23 轮审计打磨,全域)
165170

packages/core/src/catalog.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ const layer = Layer.effect(
7171
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
7272
if (provider.disabled) return false
7373
if (typeof provider.request.body.apiKey === "string") return true
74+
// V1 config migration lowers options.apiKey into api.settings.apiKey;
75+
// model resolution accepts it (session/runner/model.ts), so must availability.
76+
if (typeof provider.api.settings?.apiKey === "string") return true
7477
if (integration?.connections.length) return true
7578
return provider.integrationID === undefined && !integration
7679
}

packages/core/src/event.ts

Lines changed: 165 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -215,154 +215,175 @@ export const layerWith = (options?: LayerOptions) =>
215215
) {
216216
return Effect.gen(function* () {
217217
const durable = definition?.durable
218-
if (durable) {
219-
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
220-
if (typeof aggregateID !== "string") {
221-
yield* Effect.die(
222-
new InvalidDurableEventError({
223-
type: event.type,
224-
message: `Expected string aggregate field ${durable.aggregate}`,
225-
}),
226-
)
227-
} else {
228-
if (input && input.aggregateID !== aggregateID) {
229-
yield* Effect.die(
230-
new InvalidDurableEventError({
231-
type: event.type,
232-
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
233-
}),
218+
if (!durable) return
219+
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
220+
if (typeof aggregateID !== "string")
221+
return yield* Effect.die(
222+
new InvalidDurableEventError({
223+
type: event.type,
224+
message: `Expected string aggregate field ${durable.aggregate}`,
225+
}),
226+
)
227+
if (input && input.aggregateID !== aggregateID)
228+
return yield* Effect.die(
229+
new InvalidDurableEventError({
230+
type: event.type,
231+
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
232+
}),
233+
)
234+
const list = projectors.get(event.type) ?? []
235+
return yield* Effect.uninterruptible(
236+
Effect.gen(function* () {
237+
const committed = yield* db
238+
.transaction(() => commitTransaction(definition, durable, event, aggregateID, list, input, commit), {
239+
behavior: "immediate",
240+
})
241+
.pipe(Effect.orDie)
242+
if (committed) {
243+
yield* Effect.forEach(
244+
pubsub.durable.get(committed.aggregateID) ?? [],
245+
(wake) => PubSub.publish(wake, undefined),
246+
{ discard: true },
234247
)
235248
}
236-
const list = projectors.get(event.type) ?? []
237-
return yield* Effect.uninterruptible(
238-
Effect.gen(function* () {
239-
const committed = yield* db
240-
.transaction(
241-
() =>
242-
Effect.gen(function* () {
243-
const row = yield* db
244-
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
245-
.from(EventSequenceTable)
246-
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
247-
.get()
248-
.pipe(Effect.orDie)
249-
const latest = row?.seq ?? -1
250-
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
251-
string,
252-
unknown
253-
>
254-
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
255-
yield* Effect.die(
256-
new InvalidDurableEventError({
257-
type: event.type,
258-
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
259-
}),
260-
)
261-
}
262-
if (input && input.seq <= latest) {
263-
const stored = yield* db
264-
.select()
265-
.from(EventTable)
266-
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
267-
.get()
268-
.pipe(Effect.orDie)
269-
if (
270-
stored?.id === event.id &&
271-
stored.type === versionedType(definition.type, durable.version) &&
272-
isDeepStrictEqual(stored.data, encoded)
273-
) {
274-
if (input.ownerID && row?.ownerID == null) {
275-
yield* db
276-
.update(EventSequenceTable)
277-
.set({ owner_id: input.ownerID })
278-
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
279-
.run()
280-
.pipe(Effect.orDie)
281-
}
282-
return
283-
}
284-
yield* Effect.die(
285-
new InvalidDurableEventError({
286-
type: event.type,
287-
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
288-
}),
289-
)
290-
}
291-
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
292-
return
293-
}
294-
const seq = input?.seq ?? latest + 1
295-
if (input && seq !== latest + 1) {
296-
yield* Effect.die(
297-
new InvalidDurableEventError({
298-
type: event.type,
299-
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
300-
}),
301-
)
302-
}
303-
const stored = yield* db
304-
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
305-
.from(EventTable)
306-
.where(eq(EventTable.id, event.id))
307-
.get()
308-
.pipe(Effect.orDie)
309-
if (stored)
310-
yield* Effect.die(
311-
new InvalidDurableEventError({
312-
type: event.type,
313-
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
314-
}),
315-
)
316-
const committed = {
317-
...event,
318-
durable: { aggregateID, seq, version: durable.version },
319-
} as Payload
320-
for (const projector of list) {
321-
yield* projector(committed)
322-
}
323-
if (commit) yield* commit(seq)
324-
yield* db
325-
.insert(EventSequenceTable)
326-
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
327-
.onConflictDoUpdate({
328-
target: EventSequenceTable.aggregate_id,
329-
set: {
330-
seq,
331-
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
332-
},
333-
})
334-
.run()
335-
.pipe(Effect.orDie)
336-
yield* db
337-
.insert(EventTable)
338-
.values([
339-
{
340-
id: event.id,
341-
aggregate_id: aggregateID,
342-
seq,
343-
type: versionedType(definition.type, durable.version),
344-
data: encoded,
345-
},
346-
])
347-
.run()
348-
.pipe(Effect.orDie)
349-
return { aggregateID, seq }
350-
}),
351-
{ behavior: "immediate" },
352-
)
353-
.pipe(Effect.orDie)
354-
if (committed) {
355-
yield* Effect.forEach(
356-
pubsub.durable.get(committed.aggregateID) ?? [],
357-
(wake) => PubSub.publish(wake, undefined),
358-
{ discard: true },
359-
)
360-
}
361-
return committed
362-
}),
363-
)
249+
return committed
250+
}),
251+
)
252+
})
253+
}
254+
255+
function commitTransaction(
256+
definition: Definition,
257+
durable: NonNullable<Definition["durable"]>,
258+
event: Payload,
259+
aggregateID: string,
260+
list: Subscriber[],
261+
input?: {
262+
readonly seq: number
263+
readonly aggregateID: string
264+
readonly ownerID?: string
265+
readonly strictOwner?: boolean
266+
},
267+
commit?: (seq: number) => Effect.Effect<void>,
268+
) {
269+
return Effect.gen(function* () {
270+
const row = yield* db
271+
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
272+
.from(EventSequenceTable)
273+
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
274+
.get()
275+
.pipe(Effect.orDie)
276+
const latest = row?.seq ?? -1
277+
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<string, unknown>
278+
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
279+
yield* Effect.die(
280+
new InvalidDurableEventError({
281+
type: event.type,
282+
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
283+
}),
284+
)
285+
}
286+
if (input && input.seq <= latest)
287+
return yield* reconcileReplay(definition, durable, event, aggregateID, input, encoded, row?.ownerID)
288+
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
289+
return
290+
}
291+
const seq = input?.seq ?? latest + 1
292+
if (input && seq !== latest + 1) {
293+
yield* Effect.die(
294+
new InvalidDurableEventError({
295+
type: event.type,
296+
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
297+
}),
298+
)
299+
}
300+
const stored = yield* db
301+
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
302+
.from(EventTable)
303+
.where(eq(EventTable.id, event.id))
304+
.get()
305+
.pipe(Effect.orDie)
306+
if (stored)
307+
yield* Effect.die(
308+
new InvalidDurableEventError({
309+
type: event.type,
310+
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
311+
}),
312+
)
313+
const committed = {
314+
...event,
315+
durable: { aggregateID, seq, version: durable.version },
316+
} as Payload
317+
for (const projector of list) {
318+
yield* projector(committed)
319+
}
320+
if (commit) yield* commit(seq)
321+
yield* db
322+
.insert(EventSequenceTable)
323+
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
324+
.onConflictDoUpdate({
325+
target: EventSequenceTable.aggregate_id,
326+
set: {
327+
seq,
328+
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
329+
},
330+
})
331+
.run()
332+
.pipe(Effect.orDie)
333+
yield* db
334+
.insert(EventTable)
335+
.values([
336+
{
337+
id: event.id,
338+
aggregate_id: aggregateID,
339+
seq,
340+
type: versionedType(definition.type, durable.version),
341+
data: encoded,
342+
},
343+
])
344+
.run()
345+
.pipe(Effect.orDie)
346+
return { aggregateID, seq }
347+
})
348+
}
349+
350+
function reconcileReplay(
351+
definition: Definition,
352+
durable: NonNullable<Definition["durable"]>,
353+
event: Payload,
354+
aggregateID: string,
355+
input: { readonly seq: number; readonly ownerID?: string },
356+
encoded: Record<string, unknown>,
357+
ownerID: string | null | undefined,
358+
) {
359+
return Effect.gen(function* () {
360+
const stored = yield* db
361+
.select()
362+
.from(EventTable)
363+
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
364+
.get()
365+
.pipe(Effect.orDie)
366+
if (
367+
stored?.id === event.id &&
368+
stored.type === versionedType(definition.type, durable.version) &&
369+
isDeepStrictEqual(stored.data, encoded)
370+
) {
371+
if (input.ownerID && ownerID == null) {
372+
yield* db
373+
.update(EventSequenceTable)
374+
.set({ owner_id: input.ownerID })
375+
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
376+
.run()
377+
.pipe(Effect.orDie)
364378
}
379+
return
365380
}
381+
yield* Effect.die(
382+
new InvalidDurableEventError({
383+
type: event.type,
384+
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
385+
}),
386+
)
366387
})
367388
}
368389

packages/core/src/session.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,8 +415,15 @@ const layer = Layer.effect(
415415
})
416416
}),
417417
compact: Effect.fn("V2Session.compact")(function* (input) {
418-
yield* result.get(input.sessionID)
419-
return yield* new OperationUnavailableError({ operation: "compact" })
418+
const session = yield* result.get(input.sessionID)
419+
const compacted = yield* Effect.gen(function* () {
420+
const runner = yield* SessionRunner.Service
421+
return yield* runner.compact({ sessionID: session.id, instructions: input.prompt?.text || undefined })
422+
}).pipe(
423+
Effect.provide(locations.get(session.location)),
424+
Effect.catch(() => Effect.succeed(false)),
425+
)
426+
if (!compacted) return yield* new OperationUnavailableError({ operation: "compact" })
420427
}),
421428
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
422429
yield* result.get(sessionID)

0 commit comments

Comments
 (0)