feat(core): replace instruction checkpoints with value-delta sync (#36254)
This commit is contained in:
parent
768a69bbbd
commit
96a9731947
57 changed files with 2053 additions and 1278 deletions
|
|
@ -8,6 +8,7 @@ import { Location } from "../location"
|
|||
import { ProjectV2 } from "../project"
|
||||
import { SessionV2 } from "../session"
|
||||
import { SessionEvent } from "../session/event"
|
||||
import { SessionExecution } from "../session/execution"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { SessionStore } from "../session/store"
|
||||
import { AbsolutePath, RelativePath } from "../schema"
|
||||
|
|
@ -73,6 +74,7 @@ const layer = Layer.effect(
|
|||
const events = yield* EventV2.Service
|
||||
const project = yield* ProjectV2.Service
|
||||
const sessions = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
|
||||
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
||||
const current = yield* sessions.get(input.sessionID)
|
||||
|
|
@ -86,6 +88,12 @@ const layer = Layer.effect(
|
|||
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
|
||||
}
|
||||
|
||||
// A move must not race active execution: a mid-drain relocation would let
|
||||
// the source Location dispatch a request assembled under stale instructions
|
||||
// and history. Serialize like removal does — stop the drain, then move.
|
||||
yield* execution.interrupt(input.sessionID)
|
||||
yield* execution.awaitIdle(input.sessionID)
|
||||
|
||||
const moveChanges = input.moveChanges && source.directory !== destination.directory
|
||||
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
|
||||
if (moveChanges && !sourceRepository)
|
||||
|
|
@ -143,5 +151,5 @@ const layer = Layer.effect(
|
|||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node],
|
||||
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node, SessionExecution.node],
|
||||
})
|
||||
|
|
|
|||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -53,5 +53,6 @@ export const migrations = (
|
|||
import("./migration/20260709025533_drop-todo"),
|
||||
import("./migration/20260709163752_time_suspended"),
|
||||
import("./migration/20260709190621_session_pending_table"),
|
||||
import("./migration/20260710025429_instruction_sync"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260710025429_instruction_sync",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_seq\` integer;`)
|
||||
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`__new_instruction_entry\` (
|
||||
\`session_id\` text NOT NULL,
|
||||
\`key\` text NOT NULL,
|
||||
\`value\` text,
|
||||
\`removed\` integer DEFAULT false NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
|
||||
CONSTRAINT \`fk_instruction_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
INSERT INTO \`__new_instruction_entry\`(
|
||||
\`session_id\`, \`key\`, \`value\`, \`removed\`, \`time_created\`, \`time_updated\`
|
||||
)
|
||||
SELECT \`session_id\`, \`key\`, \`value\`, false, \`time_created\`, \`time_updated\`
|
||||
FROM \`instruction_entry\`;
|
||||
`)
|
||||
yield* tx.run(`DROP TABLE \`instruction_entry\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`__new_instruction_entry\` RENAME TO \`instruction_entry\`;`)
|
||||
yield* tx.run(`PRAGMA foreign_keys=ON;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`instruction_blob\` (
|
||||
\`hash\` text PRIMARY KEY,
|
||||
\`value\` text
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`instruction_state\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`epoch_start\` integer NOT NULL,
|
||||
\`through_seq\` integer NOT NULL,
|
||||
\`initial_values\` text NOT NULL,
|
||||
\`current_values\` text NOT NULL,
|
||||
CONSTRAINT \`fk_instruction_state_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
// Persisted System rows were exclusively pre-beta instruction prose,
|
||||
// including fork copies whose message IDs no longer match the source event.
|
||||
yield* tx.run(`DELETE FROM \`session_message\` WHERE \`type\` = 'system';`)
|
||||
yield* tx.run(`
|
||||
UPDATE \`session\`
|
||||
SET \`fork_seq\` = COALESCE(
|
||||
(
|
||||
SELECT MIN(\`seq\`) - 1
|
||||
FROM \`event\`
|
||||
WHERE \`aggregate_id\` = \`session\`.\`id\` AND \`seq\` > 0
|
||||
),
|
||||
(
|
||||
SELECT \`seq\`
|
||||
FROM \`event_sequence\`
|
||||
WHERE \`aggregate_id\` = \`session\`.\`id\`
|
||||
),
|
||||
0
|
||||
)
|
||||
WHERE \`fork_session_id\` IS NOT NULL;
|
||||
`)
|
||||
yield* tx.run(`
|
||||
UPDATE \`event\`
|
||||
SET
|
||||
\`type\` = 'session.forked.2',
|
||||
\`data\` = json_set(
|
||||
\`data\`,
|
||||
'$.parentSeq',
|
||||
COALESCE(
|
||||
(SELECT \`fork_seq\` FROM \`session\` WHERE \`id\` = \`event\`.\`aggregate_id\`),
|
||||
0
|
||||
)
|
||||
)
|
||||
WHERE \`type\` = 'session.forked.1';
|
||||
`)
|
||||
yield* tx.run(`DELETE FROM \`event\` WHERE \`type\` = 'session.instructions.updated.1';`)
|
||||
yield* tx.run(`DROP TABLE \`instruction_checkpoint\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -126,25 +126,33 @@ export default {
|
|||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`instruction_checkpoint\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`baseline\` text NOT NULL,
|
||||
\`snapshot\` text NOT NULL,
|
||||
\`baseline_seq\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_instruction_checkpoint_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
CREATE TABLE \`instruction_blob\` (
|
||||
\`hash\` text PRIMARY KEY,
|
||||
\`value\` text
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`instruction_entry\` (
|
||||
\`session_id\` text NOT NULL,
|
||||
\`key\` text NOT NULL,
|
||||
\`value\` text NOT NULL,
|
||||
\`value\` text,
|
||||
\`removed\` integer DEFAULT false NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
|
||||
CONSTRAINT \`fk_instruction_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`instruction_state\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`epoch_start\` integer NOT NULL,
|
||||
\`through_seq\` integer NOT NULL,
|
||||
\`initial_values\` text NOT NULL,
|
||||
\`current_values\` text NOT NULL,
|
||||
CONSTRAINT \`fk_instruction_state_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`message\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
|
|
@ -198,6 +206,7 @@ export default {
|
|||
\`parent_id\` text,
|
||||
\`fork_session_id\` text,
|
||||
\`fork_message_id\` text,
|
||||
\`fork_seq\` integer,
|
||||
\`slug\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`path\` text,
|
||||
|
|
|
|||
|
|
@ -137,7 +137,8 @@ export interface Interface {
|
|||
) => Effect.Effect<Payload<D>>
|
||||
readonly subscribe: Subscribe
|
||||
/**
|
||||
* Durable, ordered, gap-free per-aggregate log read. `follow: false`
|
||||
* Durable, ordered per-aggregate log read. Forked aggregates may reserve an
|
||||
* inherited prefix before their first child-authored event. `follow: false`
|
||||
* completes at the end of the log; `follow: true` replays then transitions
|
||||
* to live. Both modes emit one `Synced` marker at the captured replay
|
||||
* watermark.
|
||||
|
|
@ -203,7 +204,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
typed: new Map<string, PubSub.PubSub<Payload>>(),
|
||||
}
|
||||
const projectors = new Map<string, Subscriber[]>()
|
||||
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
|
||||
const listeners = new Array<Subscriber>()
|
||||
const { db } = yield* Database.Service
|
||||
const logReadPageSize = options?.logReadPageSize ?? 512
|
||||
|
|
@ -260,7 +260,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
}),
|
||||
)
|
||||
}
|
||||
const list = projectors.get(event.type) ?? []
|
||||
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const committed = yield* db
|
||||
|
|
@ -515,18 +515,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
}),
|
||||
)
|
||||
}
|
||||
const start = events[0]?.seq ?? 0
|
||||
for (const [index, event] of events.entries()) {
|
||||
const seq = start + index
|
||||
if (event.seq !== seq) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const event of events) {
|
||||
yield* replay(event, options)
|
||||
}
|
||||
|
|
@ -727,9 +715,10 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
|
||||
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
const list = projectors.get(definition.type) ?? []
|
||||
const key = definition.durable ? versionedType(definition.type, definition.durable.version) : definition.type
|
||||
const list = projectors.get(key) ?? []
|
||||
list.push((event) => projector(event as Payload<D>))
|
||||
projectors.set(definition.type, list)
|
||||
projectors.set(key, list)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
|
|
|
|||
|
|
@ -31,15 +31,17 @@ const layer = Layer.effect(
|
|||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
const source = (value: ReadonlyArray<File> | Instructions.Unavailable) =>
|
||||
Instructions.make({
|
||||
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
|
||||
Instructions.make<ReadonlyArray<File>>({
|
||||
key,
|
||||
codec: Schema.toCodecJson(Files),
|
||||
load: Effect.succeed(value),
|
||||
baseline: render,
|
||||
update: (_previous, current) =>
|
||||
`These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
|
||||
removed: () => "Previously loaded instructions no longer apply.",
|
||||
read: Effect.succeed(value),
|
||||
render: {
|
||||
initial: render,
|
||||
changed: (_previous, current) =>
|
||||
`These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
|
||||
removed: () => "Previously loaded instructions no longer apply.",
|
||||
},
|
||||
})
|
||||
|
||||
const observe = Effect.fn("InstructionDiscovery.observe")(function* () {
|
||||
|
|
@ -82,11 +84,7 @@ const layer = Layer.effect(
|
|||
load: () =>
|
||||
observe().pipe(
|
||||
Effect.map((files) =>
|
||||
files === Instructions.unavailable
|
||||
? source(files)
|
||||
: files.length === 0
|
||||
? Instructions.empty
|
||||
: source(files),
|
||||
Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed(source(Instructions.unavailable))),
|
||||
Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))),
|
||||
|
|
|
|||
|
|
@ -15,29 +15,34 @@ const layer = Layer.effect(
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const environment = [
|
||||
"<env>",
|
||||
` Working directory: ${location.directory}`,
|
||||
` Workspace root folder: ${location.project.directory}`,
|
||||
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
|
||||
` Platform: ${process.platform}`,
|
||||
"</env>",
|
||||
].join("\n")
|
||||
const instructions = Instructions.combine([
|
||||
Instructions.make({
|
||||
key: Instructions.Key.make("core/environment"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.succeed(environment),
|
||||
baseline: (environment) =>
|
||||
["Here is some useful information about the environment you are running in:", environment].join("\n"),
|
||||
update: (_previous, environment) => ["The environment you are running in is now:", environment].join("\n"),
|
||||
read: Effect.sync(() =>
|
||||
[
|
||||
"<env>",
|
||||
` Working directory: ${location.directory}`,
|
||||
` Workspace root folder: ${location.project.directory}`,
|
||||
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
|
||||
` Platform: ${process.platform}`,
|
||||
"</env>",
|
||||
].join("\n"),
|
||||
),
|
||||
render: {
|
||||
initial: (environment) =>
|
||||
["Here is some useful information about the environment you are running in:", environment].join("\n"),
|
||||
changed: (_previous, environment) => ["The environment you are running in is now:", environment].join("\n"),
|
||||
},
|
||||
}),
|
||||
Instructions.make({
|
||||
key: Instructions.Key.make("core/date"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
|
||||
baseline: (date) => `Today's date: ${date}`,
|
||||
update: (_previous, date) => `Today's date is now: ${date}`,
|
||||
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
|
||||
render: {
|
||||
initial: (date) => `Today's date: ${date}`,
|
||||
changed: (_previous, date) => `Today's date is now: ${date}`,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,80 +1,70 @@
|
|||
export * as Instructions from "./index"
|
||||
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { createHash } from "crypto"
|
||||
import { Instruction } from "@opencode-ai/schema/instruction"
|
||||
import { Data, Effect, Option, Schema } from "effect"
|
||||
|
||||
/**
|
||||
* Models privileged instructions as independently refreshable typed sources.
|
||||
*
|
||||
* `Source<A>` describes how to observe, compare, and render one value. `make`
|
||||
* closes over `A`, producing opaque `Instructions` that compose uniformly with
|
||||
* instructions built from other value types.
|
||||
*
|
||||
* The durable `Applied` record tracks what the model was last told, per source:
|
||||
* it is the model's current belief. Interpreters uphold one invariant —
|
||||
* `reconcile` never rewrites the baseline; it only narrates drift as update
|
||||
* text. Only `rebaseline` (compaction) and `initialize` (first step) produce
|
||||
* baseline text.
|
||||
*
|
||||
* Returning `unavailable` means observation failed temporarily. It differs from
|
||||
* removing a source from the instructions: the model's prior belief stands.
|
||||
* `reconcile` retains the applied value silently, and `rebaseline` restates the
|
||||
* belief by rendering the last-applied value instead of a live observation.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
export const Key = Instruction.Key
|
||||
export type Key = Instruction.Key
|
||||
export const Hash = Instruction.Hash
|
||||
export type Hash = Instruction.Hash
|
||||
export const Values = Instruction.Values
|
||||
export type Values = Instruction.Values
|
||||
export const Delta = Instruction.Delta
|
||||
export type Delta = Instruction.Delta
|
||||
|
||||
/** Stable namespaced identity for one independently refreshable instruction source. */
|
||||
export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._/-]*$/)).pipe(
|
||||
Schema.brand("Instructions.Key"),
|
||||
)
|
||||
export type Key = typeof Key.Type
|
||||
type NonValue = Data.TaggedEnum<{ Unavailable: {}; Removed: {} }>
|
||||
const NonValue = Data.taggedEnum<NonValue>()
|
||||
|
||||
/** Indicates that a source could not be observed without treating it as removed. */
|
||||
export const unavailable = Symbol.for("@opencode/Instructions.Unavailable")
|
||||
/** The read failed temporarily; the stored value stands. */
|
||||
export const unavailable = NonValue.Unavailable()
|
||||
export type Unavailable = typeof unavailable
|
||||
|
||||
/** Defines one typed source before its value type is hidden by `make`. */
|
||||
export interface Source<A> {
|
||||
/** An observed absence: the source exists but its value is gone. */
|
||||
export const removed = NonValue.Removed()
|
||||
export type Removed = typeof removed
|
||||
|
||||
/**
|
||||
* One composable instruction source over canonical JSON — the same
|
||||
* representation that is hashed, stored, and replayed. `make` builds one from
|
||||
* a typed definition; renderers returning `undefined` skip (undecodable or
|
||||
* unrenderable historical values).
|
||||
*/
|
||||
export interface Source {
|
||||
readonly key: Key
|
||||
readonly codec: Schema.Codec<A, Schema.Json, never, never>
|
||||
readonly load: Effect.Effect<A | Unavailable>
|
||||
readonly baseline: (current: A) => string
|
||||
readonly update: (previous: A, current: A) => string
|
||||
readonly removed?: (previous: A) => string
|
||||
readonly read: Effect.Effect<Schema.Json | Unavailable | Removed>
|
||||
readonly initial: (value: Schema.Json) => string | undefined
|
||||
readonly changed: (previous: Schema.Json, current: Schema.Json) => string | undefined
|
||||
readonly removed: (previous: Schema.Json) => string | undefined
|
||||
}
|
||||
|
||||
const InstructionsTypeId: unique symbol = Symbol.for("@opencode/Instructions")
|
||||
|
||||
/** Opaque carrier for composable instruction sources. */
|
||||
export interface Instructions {
|
||||
readonly [InstructionsTypeId]: ReadonlyArray<PackedSource>
|
||||
export declare namespace Source {
|
||||
/** The typed definition supplied when constructing a source. */
|
||||
export interface Definition<A> {
|
||||
readonly key: Key
|
||||
readonly codec: Schema.Codec<A, Schema.Json>
|
||||
readonly read: Effect.Effect<A | Unavailable | Removed>
|
||||
readonly render: {
|
||||
readonly initial: (current: A) => string
|
||||
readonly changed: (previous: A, current: A) => string
|
||||
readonly removed?: (previous: A) => string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The value last applied to the model for one admitted source. */
|
||||
export const AppliedSource = Schema.Struct({
|
||||
value: Schema.Json,
|
||||
removed: Schema.optional(Schema.NonEmptyString),
|
||||
})
|
||||
export type AppliedSource = typeof AppliedSource.Type
|
||||
/** Ordered sources; identical values render identical bytes. */
|
||||
export type Instructions = ReadonlyArray<Source>
|
||||
|
||||
/** Durable record of what the model currently believes, per source. */
|
||||
export const Applied = Schema.Record(Key, AppliedSource)
|
||||
export type Applied = Readonly<Record<string, AppliedSource>>
|
||||
export type ReadResult = ReadonlyArray<{
|
||||
readonly key: Key
|
||||
readonly value: Schema.Json | Unavailable | Removed
|
||||
}>
|
||||
|
||||
/** A rendered baseline together with the applied values it was rendered from. */
|
||||
export interface Baseline {
|
||||
readonly text: string
|
||||
readonly applied: Applied
|
||||
export interface Admission {
|
||||
readonly delta: Delta
|
||||
readonly blobs: Readonly<Record<string, Schema.Json>>
|
||||
}
|
||||
|
||||
export interface Updated {
|
||||
readonly _tag: "Updated"
|
||||
readonly text: string
|
||||
readonly applied: Applied
|
||||
}
|
||||
|
||||
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated
|
||||
|
||||
export class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
|
||||
"Instructions.InitializationBlocked",
|
||||
{ keys: Schema.Array(Key) },
|
||||
|
|
@ -92,71 +82,140 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
|
|||
}
|
||||
}
|
||||
|
||||
interface PackedSource {
|
||||
readonly key: Key
|
||||
readonly load: Effect.Effect<Observed | Unavailable>
|
||||
/** Restates the model's belief from a last-applied value when the source cannot be observed. */
|
||||
readonly recall: (stored: AppliedSource) => string | undefined
|
||||
}
|
||||
export const empty: Instructions = []
|
||||
|
||||
interface Observed {
|
||||
readonly applied: AppliedSource
|
||||
readonly baseline: () => string
|
||||
/** `undefined` means unchanged. An undecodable previous value re-renders the baseline (treat-as-new). */
|
||||
readonly update: (previous: AppliedSource) => string | undefined
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
readonly key: Key
|
||||
readonly recall: PackedSource["recall"]
|
||||
readonly observed: Observed | Unavailable
|
||||
}
|
||||
|
||||
/** The identity instruction set. */
|
||||
export const empty = instructions([])
|
||||
|
||||
/** Closes a typed source into instructions that compose with differently typed sources. */
|
||||
export function make<A>(source: Source<A>): Instructions {
|
||||
/** Closes a typed definition into one `Source`, so differently typed sources compose. */
|
||||
export function make<A>(source: Source.Definition<A>): Instructions {
|
||||
const decode = Schema.decodeUnknownOption(source.codec)
|
||||
const encode = Schema.encodeSync(source.codec)
|
||||
const equivalent = Schema.toEquivalence(source.codec)
|
||||
const baseline = (value: A) => requireText(source.key, "baseline", source.baseline(value))
|
||||
return instructions([
|
||||
const initial = (value: A) => requireText(source.key, "initial", source.render.initial(value))
|
||||
const decodeValue = (value: Schema.Json) => Option.getOrUndefined(decode(value))
|
||||
return [
|
||||
{
|
||||
key: source.key,
|
||||
recall: (stored) =>
|
||||
Option.match(decode(stored.value), {
|
||||
onNone: () => undefined,
|
||||
onSome: baseline,
|
||||
}),
|
||||
load: source.load.pipe(
|
||||
read: source.read.pipe(
|
||||
Effect.map((value) => {
|
||||
if (isUnavailable(value)) return value
|
||||
return {
|
||||
applied: {
|
||||
value: encode(value),
|
||||
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
|
||||
},
|
||||
baseline: () => baseline(value),
|
||||
update: (previous) =>
|
||||
Option.match(decode(previous.value), {
|
||||
onNone: () => baseline(value),
|
||||
onSome: (decoded) =>
|
||||
equivalent(decoded, value)
|
||||
? undefined
|
||||
: requireText(source.key, "update", source.update(decoded, value)),
|
||||
}),
|
||||
} satisfies Observed
|
||||
if (isUnavailable(value)) return unavailable
|
||||
if (isRemoved(value)) return removed
|
||||
return encode(value)
|
||||
}),
|
||||
),
|
||||
initial: (value) => {
|
||||
const decoded = decodeValue(value)
|
||||
return decoded === undefined ? undefined : initial(decoded)
|
||||
},
|
||||
changed: (previous, current) => {
|
||||
const before = decodeValue(previous)
|
||||
const after = decodeValue(current)
|
||||
if (after === undefined) return undefined
|
||||
if (before === undefined) return initial(after)
|
||||
return requireText(source.key, "changed", source.render.changed(before, after))
|
||||
},
|
||||
removed: (previous) => {
|
||||
const decoded = decodeValue(previous)
|
||||
return decoded === undefined || source.render.removed === undefined
|
||||
? undefined
|
||||
: requireText(source.key, "removed", source.render.removed(decoded))
|
||||
},
|
||||
},
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
export function combine(values: ReadonlyArray<Instructions>): Instructions {
|
||||
const sources = values.flat()
|
||||
const keys = new Set<Key>()
|
||||
for (const source of sources) {
|
||||
if (keys.has(source.key)) throw new DuplicateKeyError({ key: source.key })
|
||||
keys.add(source.key)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
export function read(value: Instructions): Effect.Effect<ReadResult> {
|
||||
return Effect.forEach(
|
||||
value,
|
||||
(source) => source.read.pipe(Effect.map((observed) => ({ key: source.key, value: observed }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
}
|
||||
|
||||
export function diff(observed: ReadResult, previous?: Values): Effect.Effect<Admission, InitializationBlocked> {
|
||||
const blocked = previous ? [] : observed.flatMap((entry) => (isUnavailable(entry.value) ? [entry.key] : []))
|
||||
if (blocked.length > 0) return Effect.fail(new InitializationBlocked({ keys: blocked }))
|
||||
const delta: Record<string, Hash | Instruction.Removed> = {}
|
||||
const blobs: Record<string, Schema.Json> = {}
|
||||
for (const entry of observed) {
|
||||
if (isUnavailable(entry.value)) continue
|
||||
if (isRemoved(entry.value)) {
|
||||
if (previous && Object.hasOwn(previous, entry.key)) delta[entry.key] = Instruction.removed
|
||||
continue
|
||||
}
|
||||
const next = hash(entry.value)
|
||||
if (previous?.[entry.key] === next) continue
|
||||
delta[entry.key] = next
|
||||
blobs[next] = entry.value
|
||||
}
|
||||
return Effect.succeed({ delta, blobs })
|
||||
}
|
||||
|
||||
export function renderInitial(value: Instructions, values: Readonly<Record<string, Schema.Json>>) {
|
||||
return render(
|
||||
value.flatMap((source) => {
|
||||
if (!Object.hasOwn(values, source.key)) return []
|
||||
const text = source.initial(values[source.key])
|
||||
return text === undefined ? [] : [text]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function renderUpdate(
|
||||
value: Instructions,
|
||||
previous: Readonly<Record<string, Schema.Json>>,
|
||||
delta: Readonly<Record<string, Option.Option<Schema.Json>>>,
|
||||
) {
|
||||
return render(
|
||||
value.flatMap((source) => {
|
||||
if (!Object.hasOwn(delta, source.key)) return []
|
||||
const current = delta[source.key]
|
||||
if (Option.isNone(current)) {
|
||||
if (!Object.hasOwn(previous, source.key)) return []
|
||||
const text = source.removed(previous[source.key])
|
||||
return text === undefined ? [] : [text]
|
||||
}
|
||||
const next = current.value
|
||||
const text = Object.hasOwn(previous, source.key)
|
||||
? source.changed(previous[source.key], next)
|
||||
: source.initial(next)
|
||||
return text === undefined ? [] : [text]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function hash(value: Schema.Json) {
|
||||
return Hash.make(createHash("sha256").update(canonical(value)).digest("hex"))
|
||||
}
|
||||
|
||||
export function applyDelta(
|
||||
values: Readonly<Record<string, Schema.Json>>,
|
||||
delta: Readonly<Record<string, Option.Option<Schema.Json>>>,
|
||||
): Readonly<Record<string, Schema.Json>> {
|
||||
const result: Record<string, Schema.Json> = { ...values }
|
||||
for (const [key, value] of Object.entries(delta)) {
|
||||
if (Option.isNone(value)) delete result[key]
|
||||
else result[key] = value.value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyHashDelta(values: Values, delta: Delta): Values {
|
||||
const result: Record<string, Hash> = { ...values }
|
||||
for (const [key, value] of Object.entries(delta)) {
|
||||
if (value === Instruction.removed) delete result[key]
|
||||
else result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed three-way diff for list-shaped sources rendering delta updates.
|
||||
* `changed` compares two values sharing a key; entries equal under it are dropped.
|
||||
*/
|
||||
export function diffByKey<A>(
|
||||
previous: ReadonlyArray<A>,
|
||||
current: ReadonlyArray<A>,
|
||||
|
|
@ -179,129 +238,31 @@ export function diffByKey<A>(
|
|||
}
|
||||
}
|
||||
|
||||
/** Combines instructions in order and rejects duplicate source keys immediately. */
|
||||
export function combine(values: ReadonlyArray<Instructions>): Instructions {
|
||||
const sources = values.flatMap((value) => value[InstructionsTypeId])
|
||||
assertUniqueKeys(sources)
|
||||
return instructions(sources)
|
||||
}
|
||||
|
||||
const observe = (value: Instructions) =>
|
||||
Effect.forEach(
|
||||
value[InstructionsTypeId],
|
||||
(source) =>
|
||||
source.load.pipe(Effect.map((observed): Entry => ({ key: source.key, recall: source.recall, observed }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
/** Creates the first baseline. Blocks rather than admit a baseline missing an unobservable source. */
|
||||
export function initialize(value: Instructions): Effect.Effect<Baseline, InitializationBlocked> {
|
||||
return observe(value).pipe(
|
||||
Effect.flatMap((entries) => {
|
||||
const blocked = entries.flatMap((entry) => (entry.observed === unavailable ? [entry.key] : []))
|
||||
if (blocked.length > 0) return new InitializationBlocked({ keys: blocked })
|
||||
const parts: string[] = []
|
||||
const applied: Record<string, AppliedSource> = {}
|
||||
for (const entry of entries) {
|
||||
if (entry.observed === unavailable) continue
|
||||
parts.push(entry.observed.baseline())
|
||||
applied[entry.key] = entry.observed.applied
|
||||
}
|
||||
return Effect.succeed({ text: render(parts), applied })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Narrates drift between current source values and the model's beliefs. Never rewrites the baseline. */
|
||||
export function reconcile(value: Instructions, previous: Applied): Effect.Effect<ReconcileResult> {
|
||||
return observe(value).pipe(
|
||||
Effect.map((entries): ReconcileResult => {
|
||||
const updates: string[] = []
|
||||
const applied: Record<string, AppliedSource> = {}
|
||||
for (const entry of entries) {
|
||||
const stored = get(previous, entry.key)
|
||||
if (entry.observed === unavailable) {
|
||||
// The prior belief stands while the source cannot be observed.
|
||||
if (stored) applied[entry.key] = stored
|
||||
continue
|
||||
}
|
||||
if (!stored) {
|
||||
updates.push(entry.observed.baseline())
|
||||
applied[entry.key] = entry.observed.applied
|
||||
continue
|
||||
}
|
||||
const text = entry.observed.update(stored)
|
||||
if (text === undefined) {
|
||||
applied[entry.key] = stored
|
||||
continue
|
||||
}
|
||||
updates.push(text)
|
||||
applied[entry.key] = entry.observed.applied
|
||||
}
|
||||
const keys = new Set<string>(entries.map((entry) => entry.key))
|
||||
for (const key of Object.keys(previous).sort()) {
|
||||
if (keys.has(key)) continue
|
||||
const removed = previous[key].removed
|
||||
// An unannounced removal retains the belief; it clears at the next rebaseline.
|
||||
if (removed === undefined) applied[key] = previous[key]
|
||||
else updates.push(removed)
|
||||
}
|
||||
if (updates.length === 0) return { _tag: "Unchanged" }
|
||||
return { _tag: "Updated", text: render(updates), applied }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Rebuilds the baseline, restating unobservable sources from the model's last-applied beliefs. */
|
||||
export function rebaseline(value: Instructions, previous: Applied): Effect.Effect<Baseline> {
|
||||
return observe(value).pipe(
|
||||
Effect.map((entries): Baseline => {
|
||||
const parts: string[] = []
|
||||
const applied: Record<string, AppliedSource> = {}
|
||||
for (const entry of entries) {
|
||||
if (entry.observed !== unavailable) {
|
||||
parts.push(entry.observed.baseline())
|
||||
applied[entry.key] = entry.observed.applied
|
||||
continue
|
||||
}
|
||||
const stored = get(previous, entry.key)
|
||||
if (!stored) continue
|
||||
const text = entry.recall(stored)
|
||||
// An undecodable belief cannot be restated; the source re-announces when observable again.
|
||||
if (text === undefined) continue
|
||||
parts.push(text)
|
||||
applied[entry.key] = stored
|
||||
}
|
||||
return { text: render(parts), applied }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function instructions(sources: ReadonlyArray<PackedSource>): Instructions {
|
||||
return { [InstructionsTypeId]: sources }
|
||||
}
|
||||
|
||||
function render(parts: ReadonlyArray<string>) {
|
||||
return parts.join("\n\n")
|
||||
}
|
||||
|
||||
function get(applied: Applied, key: Key) {
|
||||
return Object.hasOwn(applied, key) ? applied[key] : undefined
|
||||
}
|
||||
|
||||
// Reference-equality guards: `A` in a typed source may itself be JSON shaped
|
||||
// like these singletons, so identity, never structure, discriminates.
|
||||
function isUnavailable(value: unknown): value is Unavailable {
|
||||
return value === unavailable
|
||||
}
|
||||
|
||||
function isRemoved(value: unknown): value is Removed {
|
||||
return value === removed
|
||||
}
|
||||
|
||||
function canonical(value: Schema.Json): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
|
||||
if (value !== null && typeof value === "object")
|
||||
return `{${Object.entries(value)
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`)
|
||||
.join(",")}}`
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function requireText(key: Key, kind: string, text: string) {
|
||||
if (text.length === 0) throw new Error(`Instruction source ${key} rendered an empty ${kind}`)
|
||||
return text
|
||||
}
|
||||
|
||||
function assertUniqueKeys(sources: ReadonlyArray<PackedSource>) {
|
||||
const keys = new Set<Key>()
|
||||
for (const source of sources) {
|
||||
if (keys.has(source.key)) throw new DuplicateKeyError({ key: source.key })
|
||||
keys.add(source.key)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,8 +70,19 @@ export const layer = Layer.effect(
|
|||
load: Effect.fn("McpGuidance.load")(function* (selection) {
|
||||
const agent = selection.info
|
||||
if (!agent) return Instructions.empty
|
||||
const source = (value: ReadonlyArray<Summary> | Instructions.Removed) =>
|
||||
Instructions.make<ReadonlyArray<Summary>>({
|
||||
key: Instructions.Key.make("core/mcp-guidance"),
|
||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
read: Effect.succeed(value),
|
||||
render: {
|
||||
initial: render,
|
||||
changed: update,
|
||||
removed: () => "MCP server instructions are no longer available.",
|
||||
},
|
||||
})
|
||||
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
|
||||
return Instructions.empty
|
||||
return source(Instructions.removed)
|
||||
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
|
|
@ -88,15 +99,8 @@ export const layer = Layer.effect(
|
|||
)
|
||||
})
|
||||
.map((item) => ({ server: item.server, instructions: item.instructions }))
|
||||
if (visible.length === 0) return Instructions.empty
|
||||
return Instructions.make({
|
||||
key: Instructions.Key.make("core/mcp-guidance"),
|
||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
load: Effect.succeed(visible),
|
||||
baseline: render,
|
||||
update,
|
||||
removed: () => "MCP server instructions are no longer available.",
|
||||
})
|
||||
.toSorted((a, b) => a.server.localeCompare(b.server))
|
||||
return source(visible.length === 0 ? Instructions.removed : visible)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -74,14 +74,16 @@ const layer = Layer.effect(
|
|||
description: reference.description,
|
||||
}))
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
if (available.length === 0) return Instructions.empty
|
||||
return Instructions.make({
|
||||
return Instructions.make<ReadonlyArray<typeof Summary.Type>>({
|
||||
key: Instructions.Key.make("core/reference-guidance"),
|
||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
load: Effect.succeed(available),
|
||||
baseline: render,
|
||||
update,
|
||||
removed: () => "Project reference guidance is no longer available. Do not use previously listed references.",
|
||||
read: Effect.succeed(available.length === 0 ? Instructions.removed : available),
|
||||
render: {
|
||||
initial: render,
|
||||
changed: update,
|
||||
removed: () =>
|
||||
"Project reference guidance is no longer available. Do not use previously listed references.",
|
||||
},
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ export interface Interface {
|
|||
*/
|
||||
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
|
||||
/**
|
||||
* Durable, ordered, gap-free session log read. Replays public durable
|
||||
* Durable, ordered session log read. Replays public durable
|
||||
* session events after the exclusive `after` cursor, emits a `Synced`
|
||||
* marker at the captured replay watermark, then continues live when `follow`
|
||||
* is set.
|
||||
|
|
@ -384,9 +384,11 @@ const layer = Layer.effect(
|
|||
if (input.messageID && !boundary)
|
||||
return yield* new MessageNotFoundError({ sessionID: input.sessionID, messageID: input.messageID })
|
||||
const sessionID = SessionSchema.ID.create()
|
||||
const parentSeq = boundary ? boundary.seq - 1 : yield* EventV2.latestSequence(db, parent.id)
|
||||
yield* events.publish(SessionEvent.Forked, {
|
||||
sessionID,
|
||||
parentID: parent.id,
|
||||
parentSeq,
|
||||
from: input.messageID,
|
||||
})
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ const select = (
|
|||
tokens: number,
|
||||
): { readonly head: string; readonly recent: string } | undefined => {
|
||||
const conversation = messages
|
||||
.filter((message) => message.type !== "compaction")
|
||||
.filter((message) => message.type !== "compaction" && message.type !== "system")
|
||||
.flatMap((message) => {
|
||||
const text = serialize(message)
|
||||
return text ? [{ message, text }] : []
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { and, asc, desc, eq, gt, gte, ne, or, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { InstructionCheckpointTable, SessionMessageTable } from "./sql"
|
||||
import { Instructions } from "../instructions/index"
|
||||
import { InstructionState } from "./instruction-state"
|
||||
import { SessionMessageTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
|
|
@ -31,7 +33,6 @@ const messageRows = Effect.fnUntraced(function* (
|
|||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
compaction: { readonly seq: number } | undefined,
|
||||
baselineSeq?: number,
|
||||
) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
|
|
@ -39,20 +40,7 @@ const messageRows = Effect.fnUntraced(function* (
|
|||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
// Keep system updates visible in the gap between a completed compaction
|
||||
// and the next prepared step's rebaseline, when their content is not yet
|
||||
// folded into a new baseline.
|
||||
compaction
|
||||
? or(
|
||||
gte(SessionMessageTable.seq, compaction.seq),
|
||||
baselineSeq === undefined
|
||||
? undefined
|
||||
: and(eq(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)),
|
||||
)
|
||||
: undefined,
|
||||
baselineSeq === undefined
|
||||
? undefined
|
||||
: or(ne(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)),
|
||||
compaction ? gte(SessionMessageTable.seq, compaction.seq) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
|
|
@ -73,30 +61,32 @@ const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
|||
)
|
||||
|
||||
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const [epoch, compaction] = yield* Effect.all(
|
||||
[
|
||||
db
|
||||
.select({ baselineSeq: InstructionCheckpointTable.baseline_seq })
|
||||
.from(InstructionCheckpointTable)
|
||||
.where(eq(InstructionCheckpointTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
latestCompaction(db, sessionID),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
return yield* Effect.forEach(
|
||||
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)),
|
||||
decodeMessageRow,
|
||||
)
|
||||
return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow)
|
||||
})
|
||||
|
||||
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
instructions: Instructions.Instructions,
|
||||
) {
|
||||
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq)
|
||||
return yield* Effect.forEach(rows, (row) =>
|
||||
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
|
||||
)
|
||||
return yield* db
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID))
|
||||
const messages = yield* Effect.forEach(rows, (row) =>
|
||||
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
|
||||
)
|
||||
const assembled = yield* InstructionState.assemble(db, sessionID, instructions)
|
||||
return {
|
||||
initial: assembled.initial,
|
||||
entries: [...messages, ...assembled.updates].toSorted((a, b) => a.seq - b.seq),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
/** Returns the session's sole user message, or `undefined` once a second one exists. */
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
export * as InstructionCheckpoint from "./instruction-checkpoint"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { Instructions } from "../instructions/index"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { InstructionCheckpointTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decodeApplied = Schema.decodeUnknownOption(Instructions.Applied)
|
||||
|
||||
/**
|
||||
* Loads or creates the session's durable instruction checkpoint, narrating any
|
||||
* drift since the model was last told as a chronological update. Completed
|
||||
* compaction rebaselines; nothing else rewrites the baseline. Runs before
|
||||
* input promotion so a blocked first step leaves pending inputs untouched.
|
||||
*/
|
||||
export const prepare = Effect.fn("InstructionCheckpoint.prepare")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
instructions: Effect.Effect<Instructions.Instructions>,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const [value, stored, compaction] = yield* Effect.all(
|
||||
[instructions, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (!stored) {
|
||||
const baseline = yield* Instructions.initialize(value)
|
||||
const baselineSeq = yield* insert(db, sessionID, baseline)
|
||||
return { baseline: baseline.text, baselineSeq }
|
||||
}
|
||||
|
||||
// The applied record is comparison state only; an undecodable one heals by
|
||||
// treating every source as new, re-announcing baselines as updates.
|
||||
const applied = Option.getOrElse(decodeApplied(stored.snapshot), () => ({}))
|
||||
if (compaction !== undefined && compaction.seq > stored.baseline_seq) {
|
||||
const baseline = yield* Instructions.rebaseline(value, applied)
|
||||
yield* rewrite(db, sessionID, compaction.seq, baseline)
|
||||
return { baseline: baseline.text, baselineSeq: compaction.seq }
|
||||
}
|
||||
const result = yield* Instructions.reconcile(value, applied)
|
||||
if (result._tag === "Unchanged") return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
|
||||
yield* events.publish(
|
||||
SessionEvent.InstructionsUpdated,
|
||||
{ sessionID, text: result.text },
|
||||
{ commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) },
|
||||
)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
})
|
||||
|
||||
export const reset = Effect.fn("InstructionCheckpoint.reset")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
yield* db
|
||||
.delete(InstructionCheckpointTable)
|
||||
.where(eq(InstructionCheckpointTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
.from(InstructionCheckpointTable)
|
||||
.where(eq(InstructionCheckpointTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const insert = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baseline: Instructions.Baseline,
|
||||
) {
|
||||
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
|
||||
yield* db
|
||||
.insert(InstructionCheckpointTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: baseline.text,
|
||||
snapshot: baseline.applied,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return baselineSeq
|
||||
})
|
||||
|
||||
const rewrite = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
baseline: Instructions.Baseline,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(InstructionCheckpointTable)
|
||||
.set({
|
||||
baseline: baseline.text,
|
||||
snapshot: baseline.applied,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.where(eq(InstructionCheckpointTable.session_id, sessionID))
|
||||
.returning({ sessionID: InstructionCheckpointTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new Error("Instruction checkpoint not found"))
|
||||
})
|
||||
|
||||
const advance = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
applied: Instructions.Applied,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(InstructionCheckpointTable)
|
||||
.set({ snapshot: applied })
|
||||
.where(eq(InstructionCheckpointTable.session_id, sessionID))
|
||||
.returning({ sessionID: InstructionCheckpointTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new Error("Instruction checkpoint not found"))
|
||||
})
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
export * as InstructionEntry from "./instruction-entry"
|
||||
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { and, asc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
||||
import { Database } from "../database/database"
|
||||
|
|
@ -13,6 +13,8 @@ export const Key = InstructionEntry.Key
|
|||
export type Key = typeof Key.Type
|
||||
export const Info = InstructionEntry.Info
|
||||
export type Info = typeof Info.Type
|
||||
export const MaxValueBytes = InstructionEntry.MaxValueBytes
|
||||
export const ValueTooLargeError = InstructionEntry.ValueTooLargeError
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Info>>
|
||||
|
|
@ -20,7 +22,7 @@ export interface Interface {
|
|||
readonly sessionID: SessionSchema.ID
|
||||
readonly key: Key
|
||||
readonly value: Schema.Json
|
||||
}) => Effect.Effect<void>
|
||||
}) => Effect.Effect<void, InstructionEntry.ValueTooLargeError>
|
||||
readonly remove: (input: { readonly sessionID: SessionSchema.ID; readonly key: Key }) => Effect.Effect<void>
|
||||
/** Produces one Instructions source per stored entry, keyed `api/<key>`. */
|
||||
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<Instructions.Instructions>
|
||||
|
|
@ -35,18 +37,20 @@ const renderBlock = (key: Key, value: Schema.Json) =>
|
|||
|
||||
// Rendering stays mechanism-neutral: the model sees session context, not how
|
||||
// it was attached. Only chronological updates and removals carry narration.
|
||||
const source = (entry: Info) =>
|
||||
Instructions.make({
|
||||
const source = (entry: Info & { readonly removed: boolean }) =>
|
||||
Instructions.make<Schema.Json>({
|
||||
key: Instructions.Key.make(`api/${entry.key}`),
|
||||
codec: Schema.toCodecJson(Schema.Json),
|
||||
load: Effect.succeed(entry.value),
|
||||
baseline: (value) => renderBlock(entry.key, value),
|
||||
update: (_previous, value) =>
|
||||
[
|
||||
`The context under "${entry.key}" changed and supersedes the previous value:`,
|
||||
renderBlock(entry.key, value),
|
||||
].join("\n"),
|
||||
removed: () => `The context under "${entry.key}" no longer applies. Disregard it.`,
|
||||
read: Effect.succeed(entry.removed ? Instructions.removed : entry.value),
|
||||
render: {
|
||||
initial: (value) => renderBlock(entry.key, value),
|
||||
changed: (_previous, value) =>
|
||||
[
|
||||
`The context under "${entry.key}" changed and supersedes the previous value:`,
|
||||
renderBlock(entry.key, value),
|
||||
].join("\n"),
|
||||
removed: () => `The context under "${entry.key}" no longer applies. Disregard it.`,
|
||||
},
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
|
|
@ -54,15 +58,27 @@ const layer = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
const list = Effect.fn("InstructionEntry.list")(function* (sessionID: SessionSchema.ID) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
const rows = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, includeRemoved: boolean) {
|
||||
return yield* db
|
||||
.select({
|
||||
key: InstructionEntryTable.key,
|
||||
value: InstructionEntryTable.value,
|
||||
removed: InstructionEntryTable.removed,
|
||||
})
|
||||
.from(InstructionEntryTable)
|
||||
.where(eq(InstructionEntryTable.session_id, sessionID))
|
||||
.where(
|
||||
and(
|
||||
eq(InstructionEntryTable.session_id, sessionID),
|
||||
includeRemoved ? undefined : eq(InstructionEntryTable.removed, false),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(InstructionEntryTable.key))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows.map((row) => ({ key: row.key, value: row.value }))
|
||||
})
|
||||
|
||||
const list = Effect.fn("InstructionEntry.list")(function* (sessionID: SessionSchema.ID) {
|
||||
return (yield* rows(sessionID, false)).map((row) => ({ key: row.key, value: row.value }))
|
||||
})
|
||||
|
||||
const put = Effect.fn("InstructionEntry.put")(function* (input: {
|
||||
|
|
@ -70,12 +86,24 @@ const layer = Layer.effect(
|
|||
readonly key: Key
|
||||
readonly value: Schema.Json
|
||||
}) {
|
||||
const actualBytes = Buffer.byteLength(JSON.stringify(input.value), "utf8")
|
||||
if (actualBytes > MaxValueBytes)
|
||||
yield* new ValueTooLargeError({
|
||||
actualBytes,
|
||||
maxBytes: MaxValueBytes,
|
||||
message: `Instruction entry value is ${actualBytes} bytes; the limit is ${MaxValueBytes} bytes`,
|
||||
})
|
||||
const changed =
|
||||
input.value === null
|
||||
? isNotNull(InstructionEntryTable.value)
|
||||
: or(isNull(InstructionEntryTable.value), ne(InstructionEntryTable.value, input.value))
|
||||
yield* db
|
||||
.insert(InstructionEntryTable)
|
||||
.values({ session_id: input.sessionID, key: input.key, value: input.value })
|
||||
.values({ session_id: input.sessionID, key: input.key, value: input.value, removed: false })
|
||||
.onConflictDoUpdate({
|
||||
target: [InstructionEntryTable.session_id, InstructionEntryTable.key],
|
||||
set: { value: input.value, time_updated: Date.now() },
|
||||
set: { value: input.value, removed: false, time_updated: Date.now() },
|
||||
setWhere: or(eq(InstructionEntryTable.removed, true), changed),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -86,15 +114,21 @@ const layer = Layer.effect(
|
|||
readonly key: Key
|
||||
}) {
|
||||
yield* db
|
||||
.delete(InstructionEntryTable)
|
||||
.where(and(eq(InstructionEntryTable.session_id, input.sessionID), eq(InstructionEntryTable.key, input.key)))
|
||||
.update(InstructionEntryTable)
|
||||
.set({ value: null, removed: true, time_updated: Date.now() })
|
||||
.where(
|
||||
and(
|
||||
eq(InstructionEntryTable.session_id, input.sessionID),
|
||||
eq(InstructionEntryTable.key, input.key),
|
||||
eq(InstructionEntryTable.removed, false),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const load = Effect.fn("InstructionEntry.load")(function* (sessionID: SessionSchema.ID) {
|
||||
const entries = yield* list(sessionID)
|
||||
return Instructions.combine(entries.map(source))
|
||||
return Instructions.combine((yield* rows(sessionID, true)).map(source))
|
||||
})
|
||||
|
||||
return Service.of({ list, put, remove, load })
|
||||
|
|
|
|||
338
packages/core/src/session/instruction-state.ts
Normal file
338
packages/core/src/session/instruction-state.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
export * as InstructionState from "./instruction-state"
|
||||
|
||||
import { and, asc, desc, eq, gt, inArray, lte, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Option, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { EventTable } from "../event/sql"
|
||||
import { Instructions } from "../instructions/index"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { InstructionBlobTable, InstructionStateTable, SessionTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decodeInstructionsUpdated = Schema.decodeUnknownSync(SessionEvent.InstructionsUpdated.data)
|
||||
|
||||
export const prepare = Effect.fn("InstructionState.prepare")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
instructions: Instructions.Instructions,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const admission = yield* Instructions.diff(observed, stored?.current_values)
|
||||
if (!stored || Object.keys(admission.delta).length > 0) {
|
||||
yield* events.publish(
|
||||
SessionEvent.InstructionsUpdated,
|
||||
{ sessionID, delta: admission.delta },
|
||||
{
|
||||
commit: () => insertBlobs(db, admission.blobs),
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export const apply = Effect.fn("InstructionState.apply")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
seq: number,
|
||||
delta: Instructions.Delta,
|
||||
) {
|
||||
const stored = yield* find(db, sessionID)
|
||||
const current = Instructions.applyHashDelta(stored?.current_values ?? {}, delta)
|
||||
if (!stored) {
|
||||
yield* db
|
||||
.insert(InstructionStateTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
epoch_start: seq,
|
||||
through_seq: seq,
|
||||
initial_values: current,
|
||||
current_values: current,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return
|
||||
}
|
||||
yield* db
|
||||
.update(InstructionStateTable)
|
||||
.set({ through_seq: seq, current_values: current })
|
||||
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const advanceEpoch = Effect.fn("InstructionState.advanceEpoch")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
epochStart: number,
|
||||
) {
|
||||
yield* db
|
||||
.update(InstructionStateTable)
|
||||
.set({
|
||||
epoch_start: epochStart,
|
||||
through_seq: epochStart,
|
||||
initial_values: sql`${InstructionStateTable.current_values}`,
|
||||
})
|
||||
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const reset = Effect.fn("InstructionState.reset")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
yield* db
|
||||
.delete(InstructionStateTable)
|
||||
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const folded = fold(yield* instructionEvents(db, sessionID))
|
||||
if (!folded) {
|
||||
yield* reset(db, sessionID)
|
||||
return undefined
|
||||
}
|
||||
const state = {
|
||||
session_id: sessionID,
|
||||
epoch_start: folded.epochStart,
|
||||
through_seq: folded.throughSeq,
|
||||
initial_values: folded.initial,
|
||||
current_values: folded.current,
|
||||
}
|
||||
yield* db
|
||||
.insert(InstructionStateTable)
|
||||
.values(state)
|
||||
.onConflictDoUpdate({
|
||||
target: InstructionStateTable.session_id,
|
||||
set: {
|
||||
epoch_start: folded.epochStart,
|
||||
through_seq: folded.throughSeq,
|
||||
initial_values: folded.initial,
|
||||
current_values: folded.current,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return state
|
||||
})
|
||||
|
||||
export const assemble = Effect.fn("InstructionState.assemble")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
instructions: Instructions.Instructions,
|
||||
) {
|
||||
const state = yield* find(db, sessionID)
|
||||
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
|
||||
const rows = yield* instructionUpdatesAfter(db, sessionID, state.epoch_start)
|
||||
const updates = rows.map((row) => ({
|
||||
row,
|
||||
delta: decodeInstructionsUpdated(row.data).delta,
|
||||
}))
|
||||
const blobs = yield* loadBlobs(db, [
|
||||
...Object.values(state.initial_values),
|
||||
...updates.flatMap((update) =>
|
||||
Object.values(update.delta).filter((hash): hash is Instructions.Hash => hash !== "removed"),
|
||||
),
|
||||
])
|
||||
const valuesAtStart = dereference(state.initial_values, blobs)
|
||||
let values = valuesAtStart
|
||||
const result: Array<{ readonly seq: number; readonly message: SessionMessage.System }> = []
|
||||
for (const update of updates) {
|
||||
const delta = dereferenceDelta(update.delta, blobs)
|
||||
const text = Instructions.renderUpdate(instructions, values, delta)
|
||||
if (text.length > 0)
|
||||
result.push({
|
||||
seq: update.row.seq,
|
||||
message: SessionMessage.System.make({
|
||||
id: SessionMessage.ID.fromEvent(EventV2.ID.make(update.row.id)),
|
||||
type: "system",
|
||||
text,
|
||||
time: { created: DateTime.makeUnsafe(update.row.created) },
|
||||
}),
|
||||
})
|
||||
values = Instructions.applyDelta(values, delta)
|
||||
}
|
||||
return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result }
|
||||
})
|
||||
|
||||
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
.from(InstructionStateTable)
|
||||
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const stored = yield* find(db, sessionID)
|
||||
if (!stored) return yield* rebuild(db, sessionID)
|
||||
const latest = yield* db
|
||||
.select({ seq: EventTable.seq })
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, sessionID), inArray(EventTable.type, relevantEventTypes)))
|
||||
.orderBy(desc(EventTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!latest || latest.seq <= stored.through_seq) return stored
|
||||
return yield* rebuild(db, sessionID)
|
||||
})
|
||||
|
||||
const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly<Record<string, Schema.Json>>) {
|
||||
const rows = Object.entries(blobs).map(([hash, value]) => ({ hash: Instructions.Hash.make(hash), value }))
|
||||
if (rows.length === 0) return
|
||||
yield* db.insert(InstructionBlobTable).values(rows).onConflictDoNothing().run().pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const loadBlobs = Effect.fnUntraced(function* (db: DatabaseService, values: ReadonlyArray<Instructions.Hash>) {
|
||||
const hashes = [...new Set(values)]
|
||||
const batches = Array.from({ length: Math.ceil(hashes.length / 500) }, (_, index) =>
|
||||
hashes.slice(index * 500, (index + 1) * 500),
|
||||
)
|
||||
const rows = (yield* Effect.forEach(
|
||||
batches,
|
||||
(batch) =>
|
||||
db.select().from(InstructionBlobTable).where(inArray(InstructionBlobTable.hash, batch)).all().pipe(Effect.orDie),
|
||||
{ concurrency: 4 },
|
||||
)).flat()
|
||||
const blobs = new Map(rows.map((row) => [row.hash, row.value]))
|
||||
for (const hash of hashes) {
|
||||
if (!blobs.has(hash)) return yield* Effect.die(new Error(`Instruction blob not found: ${hash}`))
|
||||
}
|
||||
return blobs
|
||||
})
|
||||
|
||||
function dereference(values: Instructions.Values, blobs: ReadonlyMap<Instructions.Hash, Schema.Json>) {
|
||||
return Object.fromEntries(Object.entries(values).map(([key, hash]) => [key, requireBlob(blobs, hash)])) as Readonly<
|
||||
Record<string, Schema.Json>
|
||||
>
|
||||
}
|
||||
|
||||
function dereferenceDelta(delta: Instructions.Delta, blobs: ReadonlyMap<Instructions.Hash, Schema.Json>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(delta).map(([key, hash]) => [
|
||||
key,
|
||||
hash === "removed" ? Option.none() : Option.some(requireBlob(blobs, hash)),
|
||||
]),
|
||||
) as Readonly<Record<string, Option.Option<Schema.Json>>>
|
||||
}
|
||||
|
||||
function requireBlob(blobs: ReadonlyMap<Instructions.Hash, Schema.Json>, hash: Instructions.Hash) {
|
||||
const value = blobs.get(hash)
|
||||
if (value === undefined) throw new Error(`Instruction blob not found: ${hash}`)
|
||||
return value
|
||||
}
|
||||
|
||||
const instructionEventType = EventV2.versionedType(
|
||||
SessionEvent.InstructionsUpdated.type,
|
||||
SessionEvent.InstructionsUpdated.durable.version,
|
||||
)
|
||||
const compactionEventType = EventV2.versionedType(
|
||||
SessionEvent.Compaction.Ended.type,
|
||||
SessionEvent.Compaction.Ended.durable.version,
|
||||
)
|
||||
const movedEventType = EventV2.versionedType(SessionEvent.Moved.type, SessionEvent.Moved.durable.version)
|
||||
const revertedEventType = EventV2.versionedType(
|
||||
SessionEvent.RevertEvent.Committed.type,
|
||||
SessionEvent.RevertEvent.Committed.durable.version,
|
||||
)
|
||||
const relevantEventTypes = [instructionEventType, compactionEventType, movedEventType, revertedEventType]
|
||||
|
||||
type InstructionEventRow = typeof EventTable.$inferSelect
|
||||
|
||||
const instructionEvents = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
|
||||
return yield* eventRows(db, sessionID, relevantEventTypes)
|
||||
})
|
||||
|
||||
const instructionUpdatesAfter = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
after: number,
|
||||
) {
|
||||
return yield* eventRows(db, sessionID, [instructionEventType], after)
|
||||
})
|
||||
|
||||
const eventRows = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
types: ReadonlyArray<string>,
|
||||
after?: number,
|
||||
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
|
||||
const segments = (yield* lineage(db, sessionID)).filter(
|
||||
(segment) => after === undefined || segment.through === undefined || segment.through > after,
|
||||
)
|
||||
return (yield* Effect.forEach(segments, (segment) =>
|
||||
db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(
|
||||
and(
|
||||
eq(EventTable.aggregate_id, segment.sessionID),
|
||||
inArray(EventTable.type, types),
|
||||
segment.through === undefined ? undefined : lte(EventTable.seq, segment.through),
|
||||
after === undefined ? undefined : gt(EventTable.seq, after),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
)).flat()
|
||||
})
|
||||
|
||||
const lineage = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
through?: number,
|
||||
): Effect.fn.Return<ReadonlyArray<{ readonly sessionID: SessionSchema.ID; readonly through?: number }>> {
|
||||
const session = yield* db
|
||||
.select({ parentID: SessionTable.fork_session_id, forkSeq: SessionTable.fork_seq })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const inherited =
|
||||
session?.parentID && session.forkSeq !== null
|
||||
? yield* lineage(
|
||||
db,
|
||||
session.parentID,
|
||||
through === undefined ? session.forkSeq : Math.min(session.forkSeq, through),
|
||||
)
|
||||
: []
|
||||
return [...inherited, { sessionID, ...(through === undefined ? {} : { through }) }]
|
||||
})
|
||||
|
||||
function fold(rows: ReadonlyArray<InstructionEventRow>) {
|
||||
return rows.reduce<
|
||||
| {
|
||||
readonly epochStart: number
|
||||
readonly throughSeq: number
|
||||
readonly initial: Instructions.Values
|
||||
readonly current: Instructions.Values
|
||||
}
|
||||
| undefined
|
||||
>((state, row) => {
|
||||
if (row.type === movedEventType || row.type === revertedEventType) return undefined
|
||||
if (row.type === compactionEventType)
|
||||
return state
|
||||
? { epochStart: row.seq, throughSeq: row.seq, initial: state.current, current: state.current }
|
||||
: undefined
|
||||
if (row.type !== instructionEventType) return state
|
||||
const delta = decodeInstructionsUpdated(row.data).delta
|
||||
const current = Instructions.applyHashDelta(state?.current ?? {}, delta)
|
||||
return state
|
||||
? { ...state, throughSeq: row.seq, current }
|
||||
: { epochStart: row.seq, throughSeq: row.seq, initial: current, current }
|
||||
}, undefined)
|
||||
}
|
||||
|
|
@ -178,16 +178,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
"session.execution.succeeded": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
"session.execution.interrupted": () => clearCurrentRetry,
|
||||
"session.instructions.updated": (event) =>
|
||||
adapter.appendMessage(
|
||||
SessionMessage.System.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "system",
|
||||
text: event.data.text,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
),
|
||||
"session.instructions.updated": () => Effect.void,
|
||||
"session.synthetic": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.Synthetic.make({
|
||||
|
|
|
|||
|
|
@ -13,22 +13,18 @@ import { SessionMessage } from "./message"
|
|||
import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionPending } from "./pending"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { InstructionCheckpoint } from "./instruction-checkpoint"
|
||||
import {
|
||||
MessageTable,
|
||||
PartTable,
|
||||
InstructionCheckpointTable,
|
||||
SessionPendingTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
} from "./sql"
|
||||
import { InstructionState } from "./instruction-state"
|
||||
import { MessageTable, PartTable, SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
import { Slug } from "../util/slug"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
||||
type MessageEvent = Exclude<
|
||||
CurrentDurableEvent,
|
||||
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type | typeof SessionEvent.InstructionsUpdated.Type
|
||||
>
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
|
|
@ -212,6 +208,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||
parent_id: null,
|
||||
fork_session_id: event.data.parentID,
|
||||
fork_message_id: event.data.from,
|
||||
fork_seq: event.data.parentSeq,
|
||||
project_id: parent.project_id,
|
||||
workspace_id: parent.workspace_id,
|
||||
slug: Slug.create(),
|
||||
|
|
@ -236,23 +233,6 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||
.pipe(Effect.orDie)
|
||||
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
|
||||
|
||||
// The fork inherits the parent's transcript, so it inherits the context
|
||||
// checkpoint that transcript was built against: copied message seqs keep
|
||||
// folding at the same baseline horizon.
|
||||
const checkpoint = yield* db
|
||||
.select()
|
||||
.from(InstructionCheckpointTable)
|
||||
.where(eq(InstructionCheckpointTable.session_id, event.data.parentID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (checkpoint) {
|
||||
yield* db
|
||||
.insert(InstructionCheckpointTable)
|
||||
.values({ ...checkpoint, session_id: event.data.sessionID })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
let cursor = -1
|
||||
while (copiedSeq !== undefined) {
|
||||
const rows = yield* db
|
||||
|
|
@ -334,7 +314,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||
|
||||
cursor = rows.at(-1)!.seq
|
||||
}
|
||||
if (copiedSeq !== undefined) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq)
|
||||
yield* EventV2.reserveSequence(db, event.data.sessionID, event.data.parentSeq)
|
||||
yield* InstructionState.rebuild(db, event.data.sessionID)
|
||||
})
|
||||
|
||||
function run(db: DatabaseService, event: MessageEvent) {
|
||||
|
|
@ -522,7 +503,7 @@ const layer = Layer.effectDiscard(
|
|||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* InstructionCheckpoint.reset(db, event.data.sessionID)
|
||||
yield* InstructionState.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionV1.Event.Deleted, (event) =>
|
||||
|
|
@ -688,7 +669,9 @@ const layer = Layer.effectDiscard(
|
|||
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.InstructionsUpdated, (event) =>
|
||||
InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta),
|
||||
)
|
||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Skill.Activated, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||
|
|
@ -722,6 +705,7 @@ const layer = Layer.effectDiscard(
|
|||
yield* events.project(SessionEvent.Compaction.Ended, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
yield* InstructionState.advanceEpoch(db, event.data.sessionID, event.durable.seq)
|
||||
if (event.durable === undefined)
|
||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
if (event.data.reason === "manual")
|
||||
|
|
@ -793,7 +777,7 @@ const layer = Layer.effectDiscard(
|
|||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* InstructionCheckpoint.reset(db, event.data.sessionID)
|
||||
yield* InstructionState.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed]).pipe(
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import { InstructionEntry } from "../instruction-entry"
|
|||
import { QuestionTool } from "../../tool/question"
|
||||
import { ToolRegistry } from "../../tool/registry"
|
||||
import { ToolOutputStore } from "../../tool-output-store"
|
||||
import { InstructionCheckpoint } from "../instruction-checkpoint"
|
||||
import { InstructionState } from "../instruction-state"
|
||||
import { SessionCompaction } from "../compaction"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionHistory } from "../history"
|
||||
|
|
@ -112,6 +112,8 @@ const layer = Layer.effect(
|
|||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
return session
|
||||
})
|
||||
const isCurrentLocation = (session: SessionSchema.Info) =>
|
||||
session.location.directory === location.directory && session.location.workspaceID === location.workspaceID
|
||||
|
||||
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
|
|
@ -160,20 +162,15 @@ const layer = Layer.effect(
|
|||
assistantMessageID?: SessionMessage.ID,
|
||||
) {
|
||||
const session = yield* getSession(sessionID)
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
if (!isCurrentLocation(session)) return yield* Effect.interrupt
|
||||
yield* plugins.flush
|
||||
const agent = yield* agents.select(session.agent)
|
||||
const agentInfo = agent.info
|
||||
if (!agentInfo) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
// Establish what the model knows before admitting what the user said, so
|
||||
// a blocked first step leaves pending inputs untouched.
|
||||
const checkpoint = yield* InstructionCheckpoint.prepare(
|
||||
db,
|
||||
events,
|
||||
loadInstructions(agent, session.id),
|
||||
session.id,
|
||||
)
|
||||
const instructions = yield* loadInstructions(agent, session.id)
|
||||
yield* InstructionState.prepare(db, events, instructions, session.id)
|
||||
let currentStep = step
|
||||
if (promotion) {
|
||||
let promoted = 0
|
||||
|
|
@ -187,8 +184,8 @@ const layer = Layer.effect(
|
|||
const resolved = yield* models.resolve(session)
|
||||
const model = resolved.model
|
||||
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const history = yield* SessionHistory.entriesForRunner(db, session.id, instructions)
|
||||
const context = history.entries.map((entry) => entry.message)
|
||||
const compactionInput = { sessionID: session.id, messages: context, model }
|
||||
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
|
|
@ -201,7 +198,7 @@ const layer = Layer.effect(
|
|||
const request = LLM.request({
|
||||
model,
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), checkpoint.baseline]
|
||||
system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), history.initial]
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type { SessionSchema } from "./schema"
|
|||
import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { Instructions } from "../instructions/index"
|
||||
import type { Instruction } from "@opencode-ai/schema/instruction"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SyntheticData, UserData } from "@opencode-ai/schema/session-pending"
|
||||
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
|
||||
|
|
@ -33,6 +33,7 @@ export const SessionTable = sqliteTable(
|
|||
parent_id: text().$type<SessionSchema.ID>(),
|
||||
fork_session_id: text().$type<SessionSchema.ID>(),
|
||||
fork_message_id: text().$type<SessionMessage.ID>(),
|
||||
fork_seq: integer(),
|
||||
slug: text().notNull(),
|
||||
directory: directoryColumn().notNull(),
|
||||
path: pathColumn(),
|
||||
|
|
@ -159,18 +160,25 @@ export const InstructionEntryTable = sqliteTable(
|
|||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
key: text().notNull(),
|
||||
value: text({ mode: "json" }).notNull().$type<Schema.Json>(),
|
||||
value: text({ mode: "json" }).$type<Schema.Json>(),
|
||||
removed: integer({ mode: "boolean" }).notNull().default(false),
|
||||
...Timestamps,
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.session_id, table.key] })],
|
||||
)
|
||||
|
||||
export const InstructionCheckpointTable = sqliteTable("instruction_checkpoint", {
|
||||
export const InstructionBlobTable = sqliteTable("instruction_blob", {
|
||||
hash: text().$type<Instruction.Hash>().primaryKey(),
|
||||
value: text({ mode: "json" }).$type<Schema.Json>(),
|
||||
})
|
||||
|
||||
export const InstructionStateTable = sqliteTable("instruction_state", {
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
.primaryKey()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
baseline: text().notNull(),
|
||||
snapshot: text({ mode: "json" }).notNull().$type<Instructions.Applied>(),
|
||||
baseline_seq: integer().notNull(),
|
||||
epoch_start: integer().notNull(),
|
||||
through_seq: integer().notNull(),
|
||||
initial_values: text({ mode: "json" }).notNull().$type<Instruction.Values>(),
|
||||
current_values: text({ mode: "json" }).notNull().$type<Instruction.Values>(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ export * as SkillGuidance from "./guidance"
|
|||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { Instructions } from "../instructions/index"
|
||||
|
||||
|
|
@ -73,8 +72,6 @@ const layer = Layer.effect(
|
|||
const agent = selection.info
|
||||
if (!agent) return Instructions.empty
|
||||
const permitted = SkillV2.available(yield* skills.list(), agent)
|
||||
if (permitted.length === 0 && PermissionV2.evaluate("skill", "*", agent.permissions).effect === "deny")
|
||||
return Instructions.empty
|
||||
const available = permitted
|
||||
.flatMap((skill) =>
|
||||
skill.description === undefined || skill.autoinvoke === false
|
||||
|
|
@ -82,13 +79,15 @@ const layer = Layer.effect(
|
|||
: [{ id: skill.id, name: skill.name, description: skill.description }],
|
||||
)
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
return Instructions.make({
|
||||
return Instructions.make<ReadonlyArray<Summary>>({
|
||||
key: Instructions.Key.make("core/skill-guidance"),
|
||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
load: Effect.succeed(available),
|
||||
baseline: render,
|
||||
update,
|
||||
removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.",
|
||||
read: Effect.succeed(available.length === 0 ? Instructions.removed : available),
|
||||
render: {
|
||||
initial: render,
|
||||
changed: update,
|
||||
removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.",
|
||||
},
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export const Plugin = {
|
|||
const resolved = yield* fs.resolve(target.canonical)
|
||||
const root = yield* fs.resolve(location.directory)
|
||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||
// supplied by the core/instructions baseline) is dropped by the dirname filter.
|
||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||
const discovered = yield* fs.up({
|
||||
targets: [FILENAME],
|
||||
start: type === "directory" ? resolved : dirname(resolved),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue