feat(core): persist v2 session context epochs (#30789)

This commit is contained in:
Kit Langton 2026-06-04 23:26:43 -04:00 committed by GitHub
commit 1af8dafd3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 4861 additions and 521 deletions

View file

@ -32,5 +32,6 @@ export const migrations = (
import("./migration/20260603141458_session_input_inbox"),
import("./migration/20260603160727_jittery_ezekiel_stane"),
import("./migration/20260604172448_event_sourced_session_input"),
import("./migration/20260605003541_add_session_context_snapshot"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,21 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260605003541_add_session_context_snapshot",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_context_epoch\` (
\`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL,
\`snapshot\` text NOT NULL,
\`baseline_seq\` integer NOT NULL,
\`replacement_seq\` integer,
\`revision\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -45,6 +45,8 @@ export type Payload<D extends Definition = Definition> = {
readonly version?: number
readonly location?: Location.Ref
readonly metadata?: Record<string, unknown>
/** Internal replay marker for projectors that own non-replicated operational state. */
readonly replay?: boolean
}
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
@ -137,6 +139,8 @@ export interface PublishOptions {
readonly id?: ID
readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref
/** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */
readonly commit?: (seq: number) => Effect.Effect<void>
}
export interface Interface {
@ -215,6 +219,7 @@ export const layerWith = (options?: LayerOptions) =>
readonly ownerID?: string
readonly strictOwner?: boolean
},
commit?: (seq: number) => Effect.Effect<void>,
) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
@ -330,6 +335,7 @@ export const layerWith = (options?: LayerOptions) =>
for (const projector of list) {
yield* projector({ ...event, seq } as Payload)
}
if (commit) yield* commit(seq)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
@ -375,11 +381,18 @@ export const layerWith = (options?: LayerOptions) =>
})
}
function publishEvent<D extends Definition>(event: Payload<D>) {
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
return Effect.gen(function* () {
const durable = registry.get(event.type)?.sync !== undefined
if (!durable && commit)
return yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: "Local commit hooks require a synchronized event",
}),
)
if (durable) {
const committed = yield* commitSyncEvent(event as Payload)
const committed = yield* commitSyncEvent(event as Payload, undefined, commit)
if (committed) {
event = { ...event, seq: committed.seq }
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
@ -424,14 +437,17 @@ export const layerWith = (options?: LayerOptions) =>
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return yield* publishEvent({
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}),
data,
} as Payload<D>)
return yield* publishEvent(
{
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}),
data,
} as Payload<D>,
options?.commit,
)
})
}
@ -451,6 +467,7 @@ export const layerWith = (options?: LayerOptions) =>
type: definition.type,
version: definition.sync.version,
data: definition.decode(event.data),
replay: true,
} as Payload
const committed = yield* commitSyncEvent(payload, {
seq: event.seq,

View file

@ -0,0 +1,91 @@
export * as InstructionContext from "./instruction-context"
import { Array, Effect, Layer, Schema } from "effect"
import { isAbsolute, join, relative, sep } from "path"
import { FSUtil } from "./fs-util"
import { Flag } from "./flag/flag"
import { Global } from "./global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { SystemContext } from "./system-context"
import { SystemContextRegistry } from "./system-context-registry"
class File extends Schema.Class<File>("InstructionContext.File")({
path: AbsolutePath,
content: Schema.String,
}) {}
const Files = Schema.Array(File)
const key = SystemContext.Key.make("core/instructions")
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const registry = yield* SystemContextRegistry.Service
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
SystemContext.make({
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.",
})
const observe = Effect.fn("InstructionContext.observe")(function* () {
const start = FSUtil.resolve(location.directory)
const stop = FSUtil.resolve(location.project.directory)
const fromProject = relative(stop, start)
const insideProject =
fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
const discovered = new Set(
(Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject
? []
: yield* fs.up({
targets: ["AGENTS.md"],
start,
stop,
})
).map(FSUtil.resolve),
)
const paths = Array.dedupe([FSUtil.resolve(join(global.config, "AGENTS.md")), ...discovered])
const files = yield* Effect.forEach(
paths,
(path) =>
fs
.readFileStringSafe(path)
.pipe(
Effect.map((content) =>
content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }),
),
),
{ concurrency: "unbounded" },
)
if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
return SystemContext.unavailable
return files.filter((file): file is File => file !== undefined)
})
yield* registry.contribute({
key,
load: observe().pipe(
Effect.map((files) =>
files === SystemContext.unavailable
? source(files)
: files.length === 0
? SystemContext.empty
: source(files),
),
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
),
})
}),
)
function render(files: ReadonlyArray<File>) {
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
}

View file

@ -40,12 +40,14 @@ import { RequestExecutor } from "@opencode-ai/llm/route"
import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionRunCoordinator } from "./session/run-coordinator"
import { SystemContextBuiltIns } from "./system-context-builtins"
import { FetchHttpClient } from "effect/unstable/http"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
const location = Location.layer(ref)
const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer))
const systemContext = SystemContextBuiltIns.locationLayer
const services = Layer.mergeAll(
location,
Policy.locationLayer,
@ -60,6 +62,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Watcher.locationLayer,
Pty.locationLayer,
SkillV2.locationLayer,
systemContext,
permissionsAndTools,
LocationMutation.locationLayer.pipe(Layer.orDie),
).pipe(Layer.provideMerge(location))

View file

@ -1,54 +0,0 @@
export * as SessionSystemContext from "./session-system-context"
import { Context, DateTime, Effect, Layer } from "effect"
import { Location } from "./location"
import { SystemContext } from "./system-context"
export interface Interface {
readonly load: () => Effect.Effect<SystemContext.Snapshot>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionSystemContext") {}
export 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 context = SystemContext.struct({
environment: SystemContext.value({
key: SystemContext.Key.make("core/environment"),
load: Effect.succeed({
baseline: ["Here is some useful information about the environment you are running in:", environment].join(
"\n",
),
update: ["The environment you are running in is now:", environment].join("\n"),
}),
}),
date: SystemContext.value({
key: SystemContext.Key.make("core/date"),
load: DateTime.nowAsDate.pipe(
Effect.map((date) => ({
baseline: `Today's date: ${date.toDateString()}`,
update: `Today's date is now: ${date.toDateString()}`,
})),
),
}),
})
return Service.of({
load: Effect.fn("SessionSystemContext.load")(function* () {
return yield* SystemContext.load(context)
}),
})
}),
)
export const locationLayer = layer

View file

@ -0,0 +1,242 @@
export * as SessionContextEpoch from "./context-epoch"
import { and, eq, isNull, lt, or, sql } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import type { Database } from "../database/database"
import { EventV2 } from "../event"
import { Location } from "../location"
import { SystemContext } from "../system-context"
import { SystemContextRegistry } from "../system-context-registry"
import { SessionEvent } from "./event"
import { SessionInput } from "./input"
import { SessionMessageID } from "./message-id"
import { SessionSchema } from "./schema"
import { SessionContextEpochTable, SessionTable } from "./sql"
type DatabaseService = Database.Interface["db"]
class RevisionMismatch extends Error {}
class LocationMismatch extends Error {}
const retryRevisionMismatch = <A, E>(attempt: () => Effect.Effect<A, E>): Effect.Effect<A, E> =>
attempt().pipe(
Effect.catchDefect((defect) =>
defect instanceof RevisionMismatch
? Effect.yieldNow.pipe(Effect.andThen(retryRevisionMismatch(attempt)))
: Effect.die(defect),
),
)
interface Prepared {
readonly baseline: string
readonly baselineSeq: number
}
export function initialize(
db: DatabaseService,
context: SystemContextRegistry.Interface,
sessionID: SessionSchema.ID,
location: Location.Ref,
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location)).pipe(
Effect.withSpan("SessionContextEpoch.initialize"),
)
}
export function prepare(
db: DatabaseService,
events: EventV2.Interface,
context: SystemContextRegistry.Interface,
sessionID: SessionSchema.ID,
location: Location.Ref,
): Effect.Effect<Prepared, SystemContext.InitializationBlocked> {
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location)).pipe(
Effect.withSpan("SessionContextEpoch.prepare"),
)
}
const prepareOnce = Effect.fnUntraced(function* (
db: DatabaseService,
events: EventV2.Interface,
context: SystemContextRegistry.Interface,
sessionID: SessionSchema.ID,
location: Location.Ref,
) {
const [value, stored] = yield* Effect.all([context.load(), find(db, sessionID)], { concurrency: "unbounded" })
if (!stored) {
const generation = yield* SystemContext.initialize(value)
const baselineSeq = yield* insert(db, sessionID, location, generation)
return { baseline: generation.baseline, baselineSeq }
}
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(Effect.orDie)
const result =
stored.replacement_seq === null
? yield* SystemContext.reconcile(value, snapshot)
: yield* SystemContext.replace(value, snapshot)
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked")
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
if (result._tag === "ReplacementReady") {
const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID))
yield* replace(db, sessionID, stored.revision, replacementSeq, result.generation)
return { baseline: result.generation.baseline, baselineSeq: replacementSeq }
}
yield* events.publish(
SessionEvent.ContextUpdated,
{ sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text },
{ commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) },
)
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
})
const initializeOnce = Effect.fnUntraced(function* (
db: DatabaseService,
context: SystemContextRegistry.Interface,
sessionID: SessionSchema.ID,
location: Location.Ref,
) {
if (yield* exists(db, sessionID)) return
const generation = yield* context.load().pipe(Effect.flatMap(SystemContext.initialize))
const baselineSeq = yield* insert(db, sessionID, location, generation)
return { baseline: generation.baseline, baselineSeq }
})
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return (
(yield* db
.select({ sessionID: SessionContextEpochTable.session_id })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)) !== undefined
)
})
const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select()
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
})
export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
seq: number,
) {
return yield* db
.update(SessionContextEpochTable)
.set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` })
.where(
and(
eq(SessionContextEpochTable.session_id, sessionID),
lt(SessionContextEpochTable.baseline_seq, seq),
or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)),
),
)
.run()
.pipe(Effect.orDie)
})
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
yield* db.delete(SessionContextEpochTable).where(eq(SessionContextEpochTable.session_id, sessionID)).run().pipe(Effect.orDie)
})
const insert = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
location: Location.Ref,
generation: SystemContext.Generation,
) {
return yield* db
.transaction(
() =>
Effect.gen(function* () {
const placed = yield* db
.select({ sessionID: SessionTable.id })
.from(SessionTable)
.where(
and(
eq(SessionTable.id, sessionID),
eq(SessionTable.directory, location.directory),
location.workspaceID === undefined
? isNull(SessionTable.workspace_id)
: eq(SessionTable.workspace_id, location.workspaceID),
),
)
.get()
.pipe(Effect.orDie)
if (!placed) return yield* Effect.die(new LocationMismatch())
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
yield* db
.insert(SessionContextEpochTable)
.values({
session_id: sessionID,
baseline: generation.baseline,
snapshot: generation.snapshot,
baseline_seq: baselineSeq,
revision: 0,
})
.onConflictDoNothing()
.returning({ sessionID: SessionContextEpochTable.session_id })
.get()
.pipe(
Effect.orDie,
Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))),
)
return baselineSeq
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
})
const replace = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
expectedRevision: number,
baselineSeq: number,
generation: SystemContext.Generation,
) {
const updated = yield* db
.update(SessionContextEpochTable)
.set({
baseline: generation.baseline,
snapshot: generation.snapshot,
baseline_seq: baselineSeq,
replacement_seq: null,
revision: expectedRevision + 1,
})
.where(
and(eq(SessionContextEpochTable.session_id, sessionID), eq(SessionContextEpochTable.revision, expectedRevision)),
)
.returning({ revision: SessionContextEpochTable.revision })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new RevisionMismatch())
})
const advance = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
expectedRevision: number,
snapshot: SystemContext.Snapshot,
) {
const updated = yield* db
.update(SessionContextEpochTable)
.set({ snapshot, revision: expectedRevision + 1 })
.where(
and(
eq(SessionContextEpochTable.session_id, sessionID),
eq(SessionContextEpochTable.revision, expectedRevision),
isNull(SessionContextEpochTable.replacement_seq),
),
)
.returning({ revision: SessionContextEpochTable.revision })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new RevisionMismatch())
})

View file

@ -1,47 +1,92 @@
import { and, asc, desc, eq, gt, gte, or } from "drizzle-orm"
import { and, asc, desc, eq, gt, gte, ne, or } 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 { SessionMessageTable } from "./sql"
import { SessionContextEpochTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const compaction = yield* db
.select()
const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
const rows = yield* db
})
const messageRows = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
compaction: { readonly seq: number } | undefined,
baselineSeq?: number,
) {
return yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
compaction ? or(gte(SessionMessageTable.seq, compaction.seq)) : undefined,
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)),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
return yield* Effect.forEach(rows, (row) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
() =>
new MessageDecodeError({
sessionID: SessionSchema.ID.make(row.session_id),
messageID: SessionMessage.ID.make(row.id),
}),
),
})
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
() =>
new MessageDecodeError({
sessionID: SessionSchema.ID.make(row.session_id),
messageID: SessionMessage.ID.make(row.id),
}),
),
)
export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const [epoch, compaction] = yield* Effect.all(
[
db
.select({ baselineSeq: SessionContextEpochTable.baseline_seq })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
latestCompaction(db, sessionID),
],
{ concurrency: "unbounded" },
)
return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow)
})
export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
baselineSeq: number,
) {
return yield* Effect.forEach(
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq),
decodeMessageRow,
)
})
export * as SessionContext from "./context"

View file

@ -119,6 +119,17 @@ export namespace PromptLifecycle {
export type Promoted = typeof Promoted.Type
}
export const ContextUpdated = EventV2.define({
type: "session.next.context.updated",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
text: Schema.String,
},
})
export type ContextUpdated = typeof ContextUpdated.Type
export const Synthetic = EventV2.define({
type: "session.next.synthetic",
...options,
@ -444,6 +455,7 @@ const DurableDefinitions = [
Prompted,
PromptLifecycle.Admitted,
PromptLifecycle.Promoted,
ContextUpdated,
Synthetic,
Shell.Started,
Shell.Ended,

View file

@ -159,6 +159,15 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.next.prompt.admitted": () => Effect.void,
"session.next.prompt.promoted": () => Effect.void,
"session.next.context.updated": (event) =>
adapter.appendMessage(
new SessionMessage.System({
id: event.data.messageID,
type: "system",
text: event.data.text,
time: { created: event.data.timestamp },
}),
),
"session.next.synthetic": (event) => {
return adapter.appendMessage(
new SessionMessage.Synthetic({

View file

@ -51,6 +51,12 @@ export class Synthetic extends Schema.Class<Synthetic>("Session.Message.Syntheti
type: Schema.Literal("synthetic"),
}) {}
export class System extends Schema.Class<System>("Session.Message.System")({
...Base,
type: Schema.Literal("system"),
text: SessionEvent.ContextUpdated.data.fields.text,
}) {}
export class Shell extends Schema.Class<Shell>("Session.Message.Shell")({
...Base,
type: Schema.Literal("shell"),
@ -170,7 +176,16 @@ export class Compaction extends Schema.Class<Compaction>("Session.Message.Compac
...Base,
}) {}
export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, Shell, Assistant, Compaction])
export const Message = Schema.Union([
AgentSwitched,
ModelSwitched,
User,
Synthetic,
System,
Shell,
Assistant,
Compaction,
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Session.Message" })

View file

@ -11,6 +11,7 @@ import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater"
import { SessionInput } from "./input"
import { WorkspaceV2 } from "../workspace"
import { SessionContextEpoch } from "./context-epoch"
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
@ -259,17 +260,20 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie),
)
yield* events.project(SessionEvent.Moved, (event) =>
db
.update(SessionTable)
.set({
directory: event.data.location.directory,
path: event.data.subdirectory,
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
time_updated: DateTime.toEpochMillis(event.data.timestamp),
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
Effect.gen(function* () {
yield* db
.update(SessionTable)
.set({
directory: event.data.location.directory,
path: event.data.subdirectory,
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
time_updated: DateTime.toEpochMillis(event.data.timestamp),
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* SessionContextEpoch.reset(db, event.data.sessionID)
}),
)
yield* events.project(SessionV1.Event.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
@ -352,12 +356,18 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
)
yield* events.project(SessionEvent.ModelSwitched, (event) =>
db
.update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
Effect.gen(function* () {
yield* db
.update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* run(db, event)
if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)
}),
)
yield* events.project(SessionEvent.Prompted, (event) =>
Effect.gen(function* () {
@ -413,6 +423,12 @@ export const layer = Layer.effectDiscard(
)
}),
)
yield* events.project(SessionEvent.ContextUpdated, (event) => {
if (!event.replay || event.seq === undefined) return run(db, event)
return run(db, event).pipe(
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
)
})
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
@ -432,7 +448,12 @@ export const layer = Layer.effectDiscard(
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Delta, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => {
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
return run(db, event).pipe(
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
)
})
}),
)

View file

@ -5,6 +5,7 @@ import { Context, Effect, Schema } from "effect"
import { SessionSchema } from "../schema"
import type { MessageDecodeError } from "../error"
import { SessionRunnerModel } from "./model"
import type { SystemContext } from "../../system-context"
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
"SessionRunner.StepLimitExceededError",
@ -14,7 +15,12 @@ export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExc
},
) {}
export type RunError = LLMError | SessionRunnerModel.Error | MessageDecodeError | StepLimitExceededError
export type RunError =
| LLMError
| SessionRunnerModel.Error
| MessageDecodeError
| StepLimitExceededError
| SystemContext.InitializationBlocked
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {

View file

@ -1,4 +1,4 @@
import { LLM, LLMClient, LLMError, LLMEvent } from "@opencode-ai/llm"
import { LLM, LLMClient, LLMError, LLMEvent, SystemPart } from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Semaphore, Stream } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
@ -14,6 +14,8 @@ import { SessionRunnerModel } from "./model"
import { Database } from "../../database/database"
import { SessionInput } from "../input"
import { QuestionV2 } from "../../question"
import { SystemContextRegistry } from "../../system-context-registry"
import { SessionContextEpoch } from "../context-epoch"
/**
* Runs one durable coding-agent Session until it settles.
@ -34,8 +36,8 @@ import { QuestionV2 } from "../../question"
* - [x] Resolve the selected model through the location-scoped runner environment.
* - [ ] Load the selected agent and effective permissions.
* - [ ] Build provider/model-specific base instructions and environment facts.
* - [ ] Load configured project instructions such as `AGENTS.md`, remote instructions, and
* nearby nested instructions discovered while files are read.
* - [x] Load global and upward project `AGENTS.md` instructions.
* - [ ] Load configured and remote instructions plus nearby nested instructions discovered while files are read.
* - [ ] List available skills in the system prompt and expose a tool for loading skill bodies.
* - [ ] Resolve referenced files, directories, agents, repositories, MCP resources, and media.
* - [ ] Apply steering reminders, plugin transforms, and structured-output policy.
@ -85,6 +87,7 @@ export const layer = Layer.effect(
const tools = yield* ToolRegistry.Service
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const systemContext = yield* SystemContextRegistry.Service
const db = (yield* Database.Service).db
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
@ -95,7 +98,6 @@ export const layer = Layer.effect(
const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
return yield* store.context(sessionID)
})
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
sessionID: SessionSchema.ID,
) {
@ -126,9 +128,11 @@ export const layer = Layer.effect(
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
const runTurn = Effect.fn("SessionRunner.runTurn")(function* (
session: SessionSchema.Info,
sessionID: SessionSchema.ID,
promotion: "steer" | "queue" | undefined,
) {
const session = yield* getSession(sessionID)
const initialized = yield* SessionContextEpoch.initialize(db, systemContext, session.id, session.location)
const model = yield* models.resolve(session)
const toolFibers = yield* FiberSet.make<void, never>()
let needsContinuation = false
@ -140,9 +144,14 @@ export const layer = Layer.effect(
yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
}
}
yield* failInterruptedTools(session.id)
const context = yield* getContext(session.id)
const request = LLM.request({ model, messages: toLLMMessages(context, model), tools: yield* tools.definitions() })
const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id, session.location))
const context = yield* store.runnerContext(session.id, system.baselineSeq)
const request = LLM.request({
model,
system: system.baseline.length > 0 ? [SystemPart.make(system.baseline)] : [],
messages: toLLMMessages(context, model),
tools: yield* tools.definitions(),
})
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: session.agent ?? "build",
@ -235,16 +244,16 @@ export const layer = Layer.effect(
readonly sessionID: SessionSchema.ID
readonly force?: boolean
}) {
const session = yield* getSession(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
if (input.force !== true && !hasSteer && !hasQueue) return
yield* failInterruptedTools(input.sessionID)
let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
let openActivity = input.force === true || hasSteer || hasQueue
while (openActivity) {
let needsContinuation = true
for (let step = 0; step < MAX_STEPS; step++) {
needsContinuation = yield* runTurn(session, promotion)
needsContinuation = yield* runTurn(input.sessionID, promotion)
promotion = "steer"
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
if (!needsContinuation) break

View file

@ -111,6 +111,8 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
]
case "synthetic":
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
case "system":
return [Message.system(message.text)]
case "shell":
return [
Message.make({

View file

@ -11,6 +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 { SystemContext } from "../system-context"
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
@ -161,3 +162,15 @@ export const SessionInputTable = sqliteTable(
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq),
],
)
export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
session_id: text()
.$type<SessionSchema.ID>()
.primaryKey()
.references(() => SessionTable.id, { onDelete: "cascade" }),
baseline: text().notNull(),
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
baseline_seq: integer().notNull(),
replacement_seq: integer(),
revision: integer().notNull().default(0),
})

View file

@ -13,6 +13,10 @@ import { fromRow } from "./info"
export interface Interface {
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
readonly runnerContext: (
sessionID: SessionSchema.ID,
baselineSeq: number,
) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
readonly message: (
messageID: SessionMessage.ID,
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
@ -34,6 +38,9 @@ export const layer = Layer.effect(
context: Effect.fn("SessionStore.context")(function* (sessionID) {
return yield* SessionContext.load(db, sessionID)
}),
runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID, baselineSeq) {
return yield* SessionContext.loadForRunner(db, sessionID, baselineSeq)
}),
message: Effect.fn("SessionStore.message")(function* (messageID) {
const row = yield* db
.select()

View file

@ -0,0 +1,47 @@
export * as SystemContextBuiltIns from "./system-context-builtins"
import { DateTime, Effect, Layer, Schema } from "effect"
import { InstructionContext } from "./instruction-context"
import { Location } from "./location"
import { SystemContext } from "./system-context"
import { SystemContextRegistry } from "./system-context-registry"
const builtIns = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const registry = yield* SystemContextRegistry.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 context = SystemContext.combine([
SystemContext.make({
key: SystemContext.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"),
}),
SystemContext.make({
key: SystemContext.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}`,
}),
])
yield* registry.contribute({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
}),
)
export const layer = Layer.mergeAll(builtIns, InstructionContext.layer).pipe(
Layer.provideMerge(SystemContextRegistry.layer),
)
export const locationLayer = layer

View file

@ -0,0 +1,46 @@
export * as SystemContextRegistry from "./system-context-registry"
import { Context, Effect, Layer, Ref, Scope } from "effect"
import { SystemContext } from "./system-context"
export interface Contribution {
readonly key: SystemContext.Key
readonly load: Effect.Effect<SystemContext.SystemContext>
}
export interface Interface {
readonly contribute: (contribution: Contribution) => Effect.Effect<void, never, Scope.Scope>
readonly load: () => Effect.Effect<SystemContext.SystemContext>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SystemContextRegistry") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const contributions = yield* Ref.make<ReadonlyArray<Contribution>>([])
return Service.of({
contribute: Effect.fn("SystemContextRegistry.contribute")(function* (contribution) {
yield* Effect.acquireRelease(
Ref.modify(contributions, (current) => {
if (current.some((item) => item.key === contribution.key)) return [false, current]
return [true, [...current, contribution]]
}).pipe(
Effect.flatMap((added) =>
added ? Effect.void : Effect.die(`Duplicate system context contribution key: ${contribution.key}`),
),
Effect.as(contribution),
),
(entry) => Ref.update(contributions, (current) => current.filter((item) => item !== entry)),
)
}),
load: Effect.fn("SystemContextRegistry.load")(function* () {
const current = (yield* Ref.get(contributions)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
return SystemContext.combine(
yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }),
)
}),
})
}),
)

View file

@ -1,66 +1,89 @@
export * as SystemContext from "./system-context"
import { Effect, Schema } from "effect"
import { Hash } from "./util/hash"
import { Effect, Option, Schema } from "effect"
/**
* Models privileged system context as independently refreshable typed sources.
*
* `Source<A>` describes how to observe, compare, and render one value. `make`
* closes over `A`, producing an opaque `SystemContext` that composes uniformly
* with contexts built from other value types. Interpreters observe the composed
* context once, then produce a durable structured
* `Snapshot` alongside the exact model-visible baseline or update text.
*
* Returning `unavailable` means observation failed temporarily. It differs from
* removing a source from the context: refresh preserves the admitted snapshot,
* and replacement waits rather than silently constructing an incomplete baseline.
*
* @module
*/
/** Stable namespaced identity for one independently refreshable context source. */
export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._/-]*$/)).pipe(
Schema.brand("SystemContext.Key"),
)
export type Key = typeof Key.Type
/** Indicates that a source could not be observed without treating it as removed. */
export const unavailable = Symbol.for("@opencode/SystemContext.Unavailable")
export type Unavailable = typeof unavailable
export interface Value {
/** Full component text rendered into a new epoch baseline. */
/** Defines one typed source before its value type is hidden by `make`. */
export interface Source<A> {
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
}
const ContextTypeId: unique symbol = Symbol.for("@opencode/SystemContext")
/** Opaque carrier for composable system context sources. */
export interface SystemContext {
readonly [ContextTypeId]: ReadonlyArray<PackedSource>
}
/** Durable comparison state for one admitted source. */
export const SourceSnapshot = Schema.Struct({
value: Schema.Json,
removed: Schema.optional(Schema.NonEmptyString),
})
export type SourceSnapshot = typeof SourceSnapshot.Type
/** Durable structured comparison state for one active context generation. */
export const Snapshot = Schema.Record(Key, SourceSnapshot)
export type Snapshot = Readonly<Record<string, SourceSnapshot>>
export interface Generation {
readonly baseline: string
/** Absolute current-state text emitted when this component changes. */
readonly update: string
readonly snapshot: Snapshot
}
export interface Component<out E = never, out R = never> {
readonly key: Key
readonly load: Effect.Effect<Value | Unavailable, E, R>
}
export interface SystemContext<out E = never, out R = never> {
readonly components: ReadonlyArray<Component<E, R>>
}
export interface AvailableEntry extends Value {
readonly _tag: "Available"
readonly key: Key
readonly hash: string
}
export interface UnavailableEntry {
readonly _tag: "Unavailable"
readonly key: Key
}
export type Entry = AvailableEntry | UnavailableEntry
export interface Snapshot {
readonly entries: ReadonlyArray<Entry>
}
export interface Part {
readonly key: Key
export interface Updated {
readonly _tag: "Updated"
readonly text: string
readonly snapshot: Snapshot
}
export type Checkpoint = Readonly<Record<string, string>>
export interface Initialized {
readonly baseline: ReadonlyArray<Part>
readonly checkpoint: Checkpoint
export interface ReplacementReady {
readonly _tag: "ReplacementReady"
readonly generation: Generation
}
export interface Refreshed {
readonly changes: ReadonlyArray<Part>
readonly checkpoint: Checkpoint
export interface ReplacementBlocked {
readonly _tag: "ReplacementBlocked"
}
export type ReplacementResult = ReplacementReady | ReplacementBlocked
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | ReplacementResult
export class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
"SystemContext.InitializationBlocked",
{ keys: Schema.Array(Key) },
) {}
export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError>()("SystemContext.DuplicateKeyError", {
key: Key,
}) {
@ -69,73 +92,225 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
}
}
export const value = <E, R>(component: Component<E, R>): Component<E, R> => component
export function struct<E, R>(components: Readonly<Record<string, Component<E, R>>>): SystemContext<E, R> {
const values = Object.values(components)
assertUniqueKeys(values)
return { components: values }
interface PackedSource {
readonly key: Key
readonly load: Effect.Effect<Loaded | Unavailable>
}
export const load = <E, R>(context: SystemContext<E, R>) =>
Effect.sync(() => assertUniqueKeys(context.components)).pipe(
Effect.andThen(
Effect.forEach(context.components, (component) =>
component.load.pipe(
Effect.map(
(result): Entry =>
result === unavailable
? { _tag: "Unavailable", key: component.key }
: { _tag: "Available", key: component.key, ...result, hash: Hash.sha256(result.update) },
),
interface Loaded {
readonly baseline: () => Rendered
readonly compare: (previous: Schema.Json) => Compared
}
interface Rendered {
readonly text: string
readonly snapshot: SourceSnapshot
}
type Compared =
| { readonly _tag: "Incompatible" }
| { readonly _tag: "Unchanged" }
| { readonly _tag: "Updated"; readonly render: () => Rendered }
interface AvailableEntry extends Loaded {
readonly _tag: "Available"
readonly key: Key
}
interface UnavailableEntry {
readonly _tag: "Unavailable"
readonly key: Key
}
type Entry = AvailableEntry | UnavailableEntry
/** The identity context. */
export const empty = context([])
/** Closes a typed source into a context that composes with differently typed sources. */
export function make<A>(source: Source<A>): SystemContext {
const decode = Schema.decodeUnknownOption(source.codec)
const encode = Schema.encodeSync(source.codec)
const equivalent = Schema.toEquivalence(source.codec)
return context([
{
key: source.key,
load: source.load.pipe(
Effect.map((value) => {
if (isUnavailable(value)) return value
const snapshot = (): SourceSnapshot => ({
value: encode(value),
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
})
return {
baseline: (): Rendered => ({
text: requireText(source.key, "baseline", source.baseline(value)),
snapshot: snapshot(),
}),
compare: (previous): Compared =>
Option.match(decode(previous), {
onNone: (): Compared => ({ _tag: "Incompatible" }),
onSome: (decoded): Compared =>
equivalent(decoded, value)
? { _tag: "Unchanged" }
: {
_tag: "Updated",
render: () => ({
text: requireText(source.key, "update", source.update(decoded, value)),
snapshot: snapshot(),
}),
},
}),
}
}),
),
},
])
}
/** Combines contexts in order and rejects duplicate source keys immediately. */
export function combine(values: ReadonlyArray<SystemContext>): SystemContext {
const sources = values.flatMap((value) => value[ContextTypeId])
assertUniqueKeys(sources)
return context(sources)
}
const observe = (value: SystemContext) =>
Effect.forEach(
value[ContextTypeId],
(source) =>
source.load.pipe(
Effect.map(
(result): Entry =>
result === unavailable
? { _tag: "Unavailable", key: source.key }
: { _tag: "Available", key: source.key, ...result },
),
),
),
Effect.map((entries): Snapshot => ({ entries })),
{ concurrency: "unbounded" },
)
export function initialize(snapshot: Snapshot): Initialized {
return {
baseline: snapshot.entries.flatMap((entry) =>
entry._tag === "Available" ? [{ key: entry.key, text: entry.baseline }] : [],
),
checkpoint: nextCheckpoint(snapshot, {}),
}
}
export function refresh(snapshot: Snapshot, previous: Checkpoint): Refreshed {
return {
changes: snapshot.entries.flatMap((entry) =>
entry._tag === "Available" && getCheckpoint(previous, entry.key) !== entry.hash
? [{ key: entry.key, text: entry.update }]
: [],
),
checkpoint: nextCheckpoint(snapshot, previous),
}
}
export function render(parts: ReadonlyArray<Part>) {
return parts.map((part) => part.text).join("\n\n")
}
function nextCheckpoint(snapshot: Snapshot, previous: Checkpoint) {
return Object.fromEntries(
snapshot.entries.flatMap((entry) => {
if (entry._tag === "Available") return [[entry.key, entry.hash]]
const hash = getCheckpoint(previous, entry.key)
return hash === undefined ? [] : [[entry.key, hash]]
/** Creates the immutable baseline and durable snapshot for a new generation. */
export function initialize(value: SystemContext): Effect.Effect<Generation, InitializationBlocked> {
return observe(value).pipe(
Effect.flatMap((entries) => {
const unavailable = entries.flatMap((entry) => (entry._tag === "Unavailable" ? [entry.key] : []))
if (unavailable.length > 0) return new InitializationBlocked({ keys: unavailable })
return Effect.succeed(initializeObservation(entries))
}),
)
}
function getCheckpoint(checkpoint: Checkpoint, key: Key) {
return Object.hasOwn(checkpoint, key) ? checkpoint[key] : undefined
}
function assertUniqueKeys(components: ReadonlyArray<Component<unknown, unknown>>) {
const keys = new Set<Key>()
for (const component of components) {
if (keys.has(component.key)) throw new DuplicateKeyError({ key: component.key })
keys.add(component.key)
function initializeObservation(entries: ReadonlyArray<Entry>): Generation {
const available = entries.filter((entry): entry is AvailableEntry => entry._tag === "Available")
const rendered = available.map((entry) => [entry.key, entry.baseline()] as const)
return {
baseline: render(rendered.map(([, result]) => result.text)),
snapshot: Object.fromEntries(rendered.map(([key, result]) => [key, result.snapshot])),
}
}
/** Reconciles current source values with one active generation. */
export function reconcile(value: SystemContext, previous: Snapshot): Effect.Effect<ReconcileResult> {
return observe(value).pipe(
Effect.map((entries): ReconcileResult => {
const result = reconcileObservation(entries, previous)
if (result._tag === "Unchanged" || result._tag === "Updated") return result
return replaceObservation(entries, previous)
}),
)
}
function reconcileObservation(
entries: ReadonlyArray<Entry>,
previous: Snapshot,
): { readonly _tag: "Unchanged" } | Updated | { readonly _tag: "Replace" } {
const keys = new Set(entries.map((entry) => entry.key))
const comparisons = new Map<Key, Compared>()
for (const entry of entries) {
if (entry._tag === "Unavailable") continue
const stored = getSnapshot(previous, entry.key)
if (!stored) continue
const compared = entry.compare(stored.value)
if (compared._tag === "Incompatible") return { _tag: "Replace" }
comparisons.set(entry.key, compared)
}
for (const key of Object.keys(previous).sort()) {
if (keys.has(Key.make(key))) continue
if (previous[key].removed === undefined) return { _tag: "Replace" }
}
const snapshot: Record<string, SourceSnapshot> = {}
const updates: string[] = []
for (const entry of entries) {
const stored = getSnapshot(previous, entry.key)
if (entry._tag === "Unavailable") {
if (stored) snapshot[entry.key] = stored
continue
}
if (!stored) {
const rendered = entry.baseline()
updates.push(rendered.text)
snapshot[entry.key] = rendered.snapshot
continue
}
const compared = comparisons.get(entry.key)
if (!compared || compared._tag === "Incompatible")
throw new Error(`Missing comparison for system context source ${entry.key}`)
if (compared._tag === "Unchanged") {
snapshot[entry.key] = stored
continue
}
const rendered = compared.render()
updates.push(rendered.text)
snapshot[entry.key] = rendered.snapshot
}
for (const key of Object.keys(previous).sort()) {
if (keys.has(Key.make(key))) continue
const removed = previous[key].removed
if (removed === undefined) throw new Error(`Missing removal rendering for system context source ${key}`)
updates.push(removed)
}
if (updates.length === 0) return { _tag: "Unchanged" }
return { _tag: "Updated", text: render(updates), snapshot }
}
/** Creates a complete replacement generation or blocks while admitted context is unavailable. */
export function replace(value: SystemContext, previous: Snapshot): Effect.Effect<ReplacementResult> {
return observe(value).pipe(Effect.map((entries) => replaceObservation(entries, previous)))
}
function replaceObservation(entries: ReadonlyArray<Entry>, previous: Snapshot): ReplacementResult {
if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined))
return { _tag: "ReplacementBlocked" }
return { _tag: "ReplacementReady", generation: initializeObservation(entries) }
}
function context(sources: ReadonlyArray<PackedSource>): SystemContext {
return { [ContextTypeId]: sources }
}
function render(parts: ReadonlyArray<string>) {
return parts.join("\n\n")
}
function getSnapshot(snapshot: Snapshot, key: Key) {
return Object.hasOwn(snapshot, key) ? snapshot[key] : undefined
}
function isUnavailable(value: unknown): value is Unavailable {
return value === unavailable
}
function requireText(key: Key, kind: string, text: string) {
if (text.length === 0) throw new Error(`System context 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)
}
}