mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7b2843679 | |||
| 868c1fc596 | |||
| e8d632834c | |||
| 82d9cab48d | |||
| fb43c15f88 | |||
| ca006a2d20 |
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-g0tDvRf7MErZ1PEeUazEYi492ZHiRT8kYv3bPdkss/I=",
|
"x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=",
|
||||||
"aarch64-linux": "sha256-6sKgf3ftbIqlPxlFkoPzoWPsJp3IwXD+H3Y6g874xmk=",
|
"aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=",
|
||||||
"aarch64-darwin": "sha256-Se/Nls/KlkuK2ysDQ9DeAzSaX3NsL2iDdf/dsv2GIXc=",
|
"aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=",
|
||||||
"x86_64-darwin": "sha256-V9MCkqnvQ1nkD2PaaTfNFKkBZGymj6KxrSAK6+DTF8Y="
|
"x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-175
@@ -5,7 +5,7 @@ import { and, asc, eq, gt } from "drizzle-orm"
|
|||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
|
import { externalID, type ExternalID, withStatics } from "./schema"
|
||||||
import { Identifier } from "./util/identifier"
|
import { Identifier } from "./util/identifier"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { LayerNode } from "./effect/layer-node"
|
||||||
import { isDeepStrictEqual } from "node:util"
|
import { isDeepStrictEqual } from "node:util"
|
||||||
@@ -19,16 +19,9 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
|
|||||||
)
|
)
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
|
|
||||||
/**
|
|
||||||
* Durable aggregate continuation position for embedded replay streams.
|
|
||||||
* TODO: Decide whether a future HTTP / SDK surface should expose an opaque cursor instead.
|
|
||||||
*/
|
|
||||||
export const Cursor = NonNegativeInt.pipe(Schema.brand("EventV2.Cursor"))
|
|
||||||
export type Cursor = typeof Cursor.Type
|
|
||||||
|
|
||||||
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
|
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
|
||||||
readonly type: Type
|
readonly type: Type
|
||||||
readonly sync?: {
|
readonly durable?: {
|
||||||
readonly version: number
|
readonly version: number
|
||||||
readonly aggregate: string
|
readonly aggregate: string
|
||||||
}
|
}
|
||||||
@@ -41,20 +34,16 @@ export type Payload<D extends Definition = Definition> = {
|
|||||||
readonly id: ID
|
readonly id: ID
|
||||||
readonly type: D["type"]
|
readonly type: D["type"]
|
||||||
readonly data: Data<D>
|
readonly data: Data<D>
|
||||||
/** Durable aggregate order, populated while synchronized events are projected. */
|
readonly durable?: {
|
||||||
readonly seq?: number
|
readonly aggregateID: string
|
||||||
readonly version?: number
|
readonly seq: number
|
||||||
|
readonly version: number
|
||||||
|
}
|
||||||
readonly location?: Location.Ref
|
readonly location?: Location.Ref
|
||||||
readonly metadata?: Record<string, unknown>
|
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>
|
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
||||||
type AnyProjector = (event: Payload) => Effect.Effect<void>
|
|
||||||
export type CommitGuard = (event: Payload) => Effect.Effect<void>
|
|
||||||
export type Listener = (event: Payload) => Effect.Effect<void>
|
|
||||||
export type Sync = (event: Payload) => Effect.Effect<void>
|
|
||||||
export type Unsubscribe = Effect.Effect<void>
|
export type Unsubscribe = Effect.Effect<void>
|
||||||
|
|
||||||
export type SerializedEvent = {
|
export type SerializedEvent = {
|
||||||
@@ -65,13 +54,8 @@ export type SerializedEvent = {
|
|||||||
readonly data: Record<string, unknown>
|
readonly data: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CursorEvent<E extends Payload = Payload> = {
|
export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDurableEventError>()(
|
||||||
readonly cursor: Cursor
|
"EventV2.InvalidDurableEvent",
|
||||||
readonly event: E
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InvalidSyncEventError extends Schema.TaggedErrorClass<InvalidSyncEventError>()(
|
|
||||||
"EventV2.InvalidSyncEvent",
|
|
||||||
{
|
{
|
||||||
type: Schema.String,
|
type: Schema.String,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
@@ -83,19 +67,11 @@ export function versionedType(type: string, version: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const registry = new Map<string, Definition>()
|
export const registry = new Map<string, Definition>()
|
||||||
type SyncDefinition = Definition & {
|
const durableRegistry = new Map<string, Definition>()
|
||||||
readonly sync: NonNullable<Definition["sync"]>
|
|
||||||
readonly encode: (data: unknown) => unknown
|
|
||||||
readonly decode: (data: unknown) => unknown
|
|
||||||
}
|
|
||||||
const syncRegistry = new Map<string, SyncDefinition>()
|
|
||||||
|
|
||||||
// Synchronized events cross a JSON boundary, so their data schemas must encode and decode without services.
|
|
||||||
const syncCodec = (definition: Definition) => definition.data as Schema.Codec<unknown, unknown, never, never>
|
|
||||||
|
|
||||||
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
|
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
|
||||||
readonly type: Type
|
readonly type: Type
|
||||||
readonly sync?: {
|
readonly durable?: {
|
||||||
readonly version: number
|
readonly version: number
|
||||||
readonly aggregate: string
|
readonly aggregate: string
|
||||||
}
|
}
|
||||||
@@ -106,28 +82,25 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
|
|||||||
id: ID,
|
id: ID,
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
type: Schema.Literal(input.type),
|
type: Schema.Literal(input.type),
|
||||||
version: Schema.optional(Schema.Number),
|
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
|
||||||
location: Schema.optional(Location.Ref),
|
location: Schema.optional(Location.Ref),
|
||||||
data: Data,
|
data: Data,
|
||||||
}).annotate({ identifier: input.type })
|
}).annotate({ identifier: input.type })
|
||||||
|
|
||||||
const definition = Object.assign(Payload, {
|
const definition = Object.assign(Payload, {
|
||||||
type: input.type,
|
type: input.type,
|
||||||
...(input.sync === undefined ? {} : { sync: input.sync }),
|
...(input.durable === undefined ? {} : { durable: input.durable }),
|
||||||
data: Data,
|
data: Data,
|
||||||
})
|
})
|
||||||
const existing = registry.get(input.type)
|
const existing = registry.get(input.type)
|
||||||
if (input.sync === undefined || existing?.sync === undefined || input.sync.version >= existing.sync.version) {
|
if (
|
||||||
|
input.durable === undefined ||
|
||||||
|
existing?.durable === undefined ||
|
||||||
|
input.durable.version >= existing.durable.version
|
||||||
|
) {
|
||||||
registry.set(input.type, definition)
|
registry.set(input.type, definition)
|
||||||
}
|
}
|
||||||
if (input.sync)
|
if (input.durable) durableRegistry.set(versionedType(input.type, input.durable.version), definition)
|
||||||
syncRegistry.set(
|
|
||||||
versionedType(input.type, input.sync.version),
|
|
||||||
Object.assign(definition, {
|
|
||||||
encode: Schema.encodeUnknownSync(syncCodec(definition)),
|
|
||||||
decode: Schema.decodeUnknownSync(syncCodec(definition)),
|
|
||||||
}) as SyncDefinition,
|
|
||||||
)
|
|
||||||
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
|
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
|
||||||
Definition<Type, Schema.Struct<Fields>>
|
Definition<Type, Schema.Struct<Fields>>
|
||||||
}
|
}
|
||||||
@@ -140,7 +113,7 @@ export interface PublishOptions {
|
|||||||
readonly id?: ID
|
readonly id?: ID
|
||||||
readonly metadata?: Record<string, unknown>
|
readonly metadata?: Record<string, unknown>
|
||||||
readonly location?: Location.Ref
|
readonly location?: Location.Ref
|
||||||
/** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */
|
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
|
||||||
readonly commit?: (seq: number) => Effect.Effect<void>
|
readonly commit?: (seq: number) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,14 +125,10 @@ export interface Interface {
|
|||||||
) => Effect.Effect<Payload<D>>
|
) => Effect.Effect<Payload<D>>
|
||||||
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
||||||
readonly all: () => Stream.Stream<Payload>
|
readonly all: () => Stream.Stream<Payload>
|
||||||
readonly aggregateEvents: (input: {
|
readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload>
|
||||||
readonly aggregateID: string
|
/** @deprecated Use `all()` and consume the returned stream. */
|
||||||
readonly after?: Cursor
|
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||||
}) => Stream.Stream<CursorEvent>
|
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
||||||
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
|
|
||||||
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
|
|
||||||
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
|
|
||||||
readonly project: <D extends Definition>(definition: D, projector: Projector<D>) => Effect.Effect<void>
|
|
||||||
readonly replay: (
|
readonly replay: (
|
||||||
event: SerializedEvent,
|
event: SerializedEvent,
|
||||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||||
@@ -182,37 +151,37 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
Layer.effect(
|
Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const all = yield* PubSub.unbounded<Payload>()
|
const pubsub = {
|
||||||
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>()
|
all: yield* PubSub.unbounded<Payload>(),
|
||||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
|
||||||
const projectors = new Map<string, AnyProjector[]>()
|
typed: new Map<string, PubSub.PubSub<Payload>>(),
|
||||||
const commitGuards = new Array<CommitGuard>()
|
}
|
||||||
const listeners = new Array<Listener>()
|
const projectors = new Map<string, Subscriber[]>()
|
||||||
const syncHandlers = new Array<Sync>()
|
const listeners = new Array<Subscriber>()
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
|
|
||||||
const getOrCreate = (definition: Definition) =>
|
const getOrCreate = (definition: Definition) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const existing = typed.get(definition.type)
|
const existing = pubsub.typed.get(definition.type)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const pubsub = yield* PubSub.unbounded<Payload>()
|
const created = yield* PubSub.unbounded<Payload>()
|
||||||
typed.set(definition.type, pubsub)
|
pubsub.typed.set(definition.type, created)
|
||||||
return pubsub
|
return created
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* PubSub.shutdown(all)
|
yield* PubSub.shutdown(pubsub.all)
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
synchronized.values(),
|
pubsub.durable.values(),
|
||||||
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
|
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
|
yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function commitSyncEvent(
|
function commitDurableEvent(
|
||||||
event: Payload,
|
event: Payload,
|
||||||
input?: {
|
input?: {
|
||||||
readonly seq: number
|
readonly seq: number
|
||||||
@@ -224,28 +193,20 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
) {
|
) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const definition = registry.get(event.type)
|
const definition = registry.get(event.type)
|
||||||
const sync = definition?.sync
|
const durable = definition?.durable
|
||||||
if (sync) {
|
if (durable) {
|
||||||
if (event.version !== sync.version) {
|
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
|
||||||
yield* Effect.die(
|
|
||||||
new InvalidSyncEventError({
|
|
||||||
type: event.type,
|
|
||||||
message: `Expected event version ${sync.version}, got ${event.version}`,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
|
|
||||||
if (typeof aggregateID !== "string") {
|
if (typeof aggregateID !== "string") {
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: `Expected string aggregate field ${sync.aggregate}`,
|
message: `Expected string aggregate field ${durable.aggregate}`,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
if (input && input.aggregateID !== aggregateID) {
|
if (input && input.aggregateID !== aggregateID) {
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
|
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
|
||||||
}),
|
}),
|
||||||
@@ -265,12 +226,12 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
const latest = row?.seq ?? -1
|
const latest = row?.seq ?? -1
|
||||||
const encoded = syncRegistry
|
const encoded = Schema.encodeUnknownSync(
|
||||||
.get(versionedType(definition.type, sync.version))!
|
definition.data as Schema.Codec<unknown, unknown, never, never>,
|
||||||
.encode(event.data) as Record<string, unknown>
|
)(event.data) as Record<string, unknown>
|
||||||
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
|
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
||||||
}),
|
}),
|
||||||
@@ -285,7 +246,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (
|
if (
|
||||||
stored?.id === event.id &&
|
stored?.id === event.id &&
|
||||||
stored.type === versionedType(definition.type, sync.version) &&
|
stored.type === versionedType(definition.type, durable.version) &&
|
||||||
isDeepStrictEqual(stored.data, encoded)
|
isDeepStrictEqual(stored.data, encoded)
|
||||||
) {
|
) {
|
||||||
if (input.ownerID && row?.ownerID == null) {
|
if (input.ownerID && row?.ownerID == null) {
|
||||||
@@ -299,7 +260,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
|
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
|
||||||
}),
|
}),
|
||||||
@@ -311,7 +272,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
const seq = input?.seq ?? latest + 1
|
const seq = input?.seq ?? latest + 1
|
||||||
if (input && seq !== latest + 1) {
|
if (input && seq !== latest + 1) {
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
||||||
}),
|
}),
|
||||||
@@ -325,16 +286,17 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (stored)
|
if (stored)
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
for (const guard of commitGuards) {
|
const committed = {
|
||||||
yield* guard(event)
|
...event,
|
||||||
}
|
durable: { aggregateID, seq, version: durable.version },
|
||||||
|
} as Payload
|
||||||
for (const projector of list) {
|
for (const projector of list) {
|
||||||
yield* projector({ ...event, seq } as Payload)
|
yield* projector(committed)
|
||||||
}
|
}
|
||||||
if (commit) yield* commit(seq)
|
if (commit) yield* commit(seq)
|
||||||
yield* db
|
yield* db
|
||||||
@@ -356,7 +318,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
id: event.id,
|
id: event.id,
|
||||||
aggregate_id: aggregateID,
|
aggregate_id: aggregateID,
|
||||||
seq,
|
seq,
|
||||||
type: versionedType(definition.type, sync.version),
|
type: versionedType(definition.type, durable.version),
|
||||||
data: encoded,
|
data: encoded,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -369,8 +331,8 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (committed) {
|
if (committed) {
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
synchronized.get(committed.aggregateID) ?? [],
|
pubsub.durable.get(committed.aggregateID) ?? [],
|
||||||
(pubsub) => PubSub.publish(pubsub, undefined),
|
(wake) => PubSub.publish(wake, undefined),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -384,19 +346,25 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
|
|
||||||
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
|
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const durable = registry.get(event.type)?.sync !== undefined
|
const definition = registry.get(event.type)
|
||||||
if (!durable && commit)
|
if (!definition?.durable && commit)
|
||||||
return yield* Effect.die(
|
return yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: "Local commit hooks require a synchronized event",
|
message: "Local commit hooks require a durable event",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (durable) {
|
if (definition?.durable) {
|
||||||
const committed = yield* commitSyncEvent(event as Payload, undefined, commit)
|
const committed = yield* commitDurableEvent(event as Payload, undefined, commit)
|
||||||
if (committed) {
|
if (committed) {
|
||||||
event = { ...event, seq: committed.seq }
|
event = {
|
||||||
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
|
...event,
|
||||||
|
durable: {
|
||||||
|
aggregateID: committed.aggregateID,
|
||||||
|
seq: committed.seq,
|
||||||
|
version: definition.durable.version,
|
||||||
|
},
|
||||||
|
}
|
||||||
yield* notify(event as Payload, true)
|
yield* notify(event as Payload, true)
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
@@ -406,12 +374,11 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect<void>) =>
|
const observe = (event: Payload, observer: (event: Payload) => Effect.Effect<void>) =>
|
||||||
Effect.suspend(() => observer(event)).pipe(
|
Effect.suspend(() => observer(event)).pipe(
|
||||||
Effect.catchCauseIf(
|
Effect.catchCauseIf(
|
||||||
(cause) => !Cause.hasInterrupts(cause),
|
(cause) => !Cause.hasInterrupts(cause),
|
||||||
(cause) =>
|
(cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }),
|
||||||
Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -419,12 +386,12 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
listeners,
|
listeners,
|
||||||
(listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)),
|
(listener) => (isolateListeners ? observe(event, listener) : listener(event)),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
const pubsub = typed.get(event.type)
|
const typed = pubsub.typed.get(event.type)
|
||||||
if (pubsub) yield* PubSub.publish(pubsub, event)
|
if (typed) yield* PubSub.publish(typed, event)
|
||||||
yield* PubSub.publish(all, event)
|
yield* PubSub.publish(pubsub.all, event)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,7 +408,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
id: options?.id ?? ID.create(),
|
id: options?.id ?? ID.create(),
|
||||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||||
type: definition.type,
|
type: definition.type,
|
||||||
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
|
|
||||||
...(location ? { location } : {}),
|
...(location ? { location } : {}),
|
||||||
data,
|
data,
|
||||||
} as Payload<D>,
|
} as Payload<D>,
|
||||||
@@ -455,27 +421,37 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||||
) {
|
) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const definition = syncRegistry.get(event.type)
|
const definition = durableRegistry.get(event.type)
|
||||||
if (!definition) {
|
if (!definition?.durable) {
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
|
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
const payload = {
|
const payload = {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
type: definition.type,
|
type: definition.type,
|
||||||
version: definition.sync.version,
|
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(
|
||||||
data: definition.decode(event.data),
|
event.data,
|
||||||
replay: true,
|
),
|
||||||
} as Payload
|
} as Payload
|
||||||
const committed = yield* commitSyncEvent(payload, {
|
const committed = yield* commitDurableEvent(payload, {
|
||||||
seq: event.seq,
|
seq: event.seq,
|
||||||
aggregateID: event.aggregateID,
|
aggregateID: event.aggregateID,
|
||||||
ownerID: options?.ownerID,
|
ownerID: options?.ownerID,
|
||||||
strictOwner: options?.strictOwner,
|
strictOwner: options?.strictOwner,
|
||||||
})
|
})
|
||||||
if (committed && options?.publish) {
|
if (committed && options?.publish) {
|
||||||
yield* notify({ ...payload, seq: committed.seq }, true)
|
yield* notify(
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
durable: {
|
||||||
|
aggregateID: committed.aggregateID,
|
||||||
|
seq: committed.seq,
|
||||||
|
version: definition.durable.version,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -490,7 +466,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
if (!source) return undefined
|
if (!source) return undefined
|
||||||
if (events.some((event) => event.aggregateID !== source)) {
|
if (events.some((event) => event.aggregateID !== source)) {
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: events[0]?.type ?? "unknown",
|
type: events[0]?.type ?? "unknown",
|
||||||
message: "Replay events must belong to the same aggregate",
|
message: "Replay events must belong to the same aggregate",
|
||||||
}),
|
}),
|
||||||
@@ -501,7 +477,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
const seq = start + index
|
const seq = start + index
|
||||||
if (event.seq !== seq) {
|
if (event.seq !== seq) {
|
||||||
yield* Effect.die(
|
yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidDurableEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
|
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
|
||||||
}),
|
}),
|
||||||
@@ -540,22 +516,18 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
Stream.map((event) => event as Payload<D>),
|
Stream.map((event) => event as Payload<D>),
|
||||||
)
|
)
|
||||||
|
|
||||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
|
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
|
||||||
|
|
||||||
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => {
|
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
||||||
const definition = syncRegistry.get(event.type)
|
const definition = durableRegistry.get(event.type)
|
||||||
if (!definition) {
|
if (!definition?.durable) {
|
||||||
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` })
|
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
cursor: Cursor.make(event.seq),
|
|
||||||
event: {
|
|
||||||
id: event.id,
|
id: event.id,
|
||||||
type: definition.type,
|
type: definition.type,
|
||||||
version: definition.sync.version,
|
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
||||||
seq: event.seq,
|
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
|
||||||
data: definition.decode(event.data),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,43 +555,40 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const subscribeSynchronized = (aggregateID: string) =>
|
const subscribeDurable = (aggregateID: string) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const pubsub = yield* PubSub.sliding<void>(1)
|
const wake = yield* PubSub.sliding<void>(1)
|
||||||
const subscription = yield* PubSub.subscribe(pubsub)
|
const subscription = yield* PubSub.subscribe(wake)
|
||||||
yield* Effect.acquireRelease(
|
yield* Effect.acquireRelease(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const pubsubs = synchronized.get(aggregateID) ?? new Set()
|
const wakes = pubsub.durable.get(aggregateID) ?? new Set()
|
||||||
pubsubs.add(pubsub)
|
wakes.add(wake)
|
||||||
synchronized.set(aggregateID, pubsubs)
|
pubsub.durable.set(aggregateID, wakes)
|
||||||
}),
|
}),
|
||||||
() =>
|
() =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const pubsubs = synchronized.get(aggregateID)
|
const wakes = pubsub.durable.get(aggregateID)
|
||||||
pubsubs?.delete(pubsub)
|
wakes?.delete(wake)
|
||||||
if (pubsubs?.size === 0) synchronized.delete(aggregateID)
|
if (wakes?.size === 0) pubsub.durable.delete(aggregateID)
|
||||||
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))),
|
}).pipe(Effect.andThen(PubSub.shutdown(wake))),
|
||||||
)
|
)
|
||||||
return subscription
|
return subscription
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamEvents = (input: {
|
const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> =>
|
||||||
readonly aggregateID: string
|
|
||||||
readonly after?: Cursor
|
|
||||||
}): Stream.Stream<CursorEvent> =>
|
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const synchronized = yield* subscribeSynchronized(input.aggregateID)
|
const wakes = yield* subscribeDurable(input.aggregateID)
|
||||||
let cursor = input.after ?? -1
|
let sequence = input.after ?? -1
|
||||||
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe(
|
const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
|
||||||
Effect.tap((events) =>
|
Effect.tap((events) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
cursor = events.at(-1)?.cursor ?? cursor
|
sequence = events.at(-1)?.durable?.seq ?? sequence
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const historical = yield* read
|
const historical = yield* read
|
||||||
const live = Stream.fromSubscription(synchronized).pipe(
|
const live = Stream.fromSubscription(wakes).pipe(
|
||||||
Stream.mapEffect(() => read),
|
Stream.mapEffect(() => read),
|
||||||
Stream.flattenIterable,
|
Stream.flattenIterable,
|
||||||
)
|
)
|
||||||
@@ -627,7 +596,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
|
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
listeners.push(listener)
|
listeners.push(listener)
|
||||||
return Effect.sync(() => {
|
return Effect.sync(() => {
|
||||||
@@ -636,21 +605,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
|
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
|
||||||
Effect.sync(() => {
|
|
||||||
syncHandlers.push(handler)
|
|
||||||
return Effect.sync(() => {
|
|
||||||
const index = syncHandlers.indexOf(handler)
|
|
||||||
if (index >= 0) syncHandlers.splice(index, 1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
commitGuards.push(guard)
|
|
||||||
})
|
|
||||||
|
|
||||||
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
|
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const list = projectors.get(definition.type) ?? []
|
const list = projectors.get(definition.type) ?? []
|
||||||
list.push((event) => projector(event as Payload<D>))
|
list.push((event) => projector(event as Payload<D>))
|
||||||
@@ -661,10 +616,8 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
publish,
|
publish,
|
||||||
subscribe,
|
subscribe,
|
||||||
all: streamAll,
|
all: streamAll,
|
||||||
aggregateEvents: streamEvents,
|
durable,
|
||||||
sync,
|
|
||||||
listen,
|
listen,
|
||||||
beforeCommit,
|
|
||||||
project,
|
project,
|
||||||
replay,
|
replay,
|
||||||
replayAll,
|
replayAll,
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { FSUtil } from "./fs-util"
|
|||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { PositiveInt, RelativePath } from "./schema"
|
import { PositiveInt, RelativePath } from "./schema"
|
||||||
import { FileSystemSearch } from "./filesystem/search"
|
import { FileSystemSearch } from "./filesystem/search"
|
||||||
import { Entry, Match } from "./filesystem/schema"
|
import { Entry, Match, PathError } from "./filesystem/schema"
|
||||||
export { Entry, Match, Submatch } from "./filesystem/schema"
|
export { Entry, Match, PathError, Submatch } from "./filesystem/schema"
|
||||||
|
|
||||||
export const ReadInput = Schema.Struct({
|
export const ReadInput = Schema.Struct({
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
@@ -58,8 +58,10 @@ export const Event = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
readonly read: (
|
||||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
input: ReadInput,
|
||||||
|
) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, PathError | FSUtil.Error>
|
||||||
|
readonly list: (input?: ListInput) => Effect.Effect<Entry[], PathError | FSUtil.Error>
|
||||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||||
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
|
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
|
||||||
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
|
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
|
||||||
@@ -77,9 +79,9 @@ const baseLayer = Layer.effect(
|
|||||||
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
|
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
|
||||||
const absolute = path.resolve(location.directory, input ?? ".")
|
const absolute = path.resolve(location.directory, input ?? ".")
|
||||||
if (!FSUtil.contains(location.directory, absolute))
|
if (!FSUtil.contains(location.directory, absolute))
|
||||||
return yield* Effect.die(new Error("Path escapes the location"))
|
return yield* new PathError({ path: input ?? ".", reason: "lexical_escape" })
|
||||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
const real = yield* fs.realPath(absolute)
|
||||||
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
if (!FSUtil.contains(root, real)) return yield* new PathError({ path: input ?? ".", reason: "symlink_escape" })
|
||||||
return { absolute, real, directory: location.directory, root }
|
return { absolute, real, directory: location.directory, root }
|
||||||
})
|
})
|
||||||
return Service.of({
|
return Service.of({
|
||||||
@@ -88,19 +90,18 @@ const baseLayer = Layer.effect(
|
|||||||
grep: search.grep,
|
grep: search.grep,
|
||||||
read: Effect.fn("FileSystem.read")(function* (input) {
|
read: Effect.fn("FileSystem.read")(function* (input) {
|
||||||
const target = yield* resolve(input.path)
|
const target = yield* resolve(input.path)
|
||||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
const info = yield* fs.stat(target.real)
|
||||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
if (info.type !== "File") return yield* new PathError({ path: input.path, reason: "not_file" })
|
||||||
return {
|
return {
|
||||||
content: yield* fs.readFile(target.real).pipe(Effect.orDie),
|
content: yield* fs.readFile(target.real),
|
||||||
mime: FSUtil.mimeType(target.real),
|
mime: FSUtil.mimeType(target.real),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
list: Effect.fn("FileSystem.list")(function* (input = {}) {
|
list: Effect.fn("FileSystem.list")(function* (input = {}) {
|
||||||
const target = yield* resolve(input.path)
|
const target = yield* resolve(input.path)
|
||||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
const info = yield* fs.stat(target.real)
|
||||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
if (info.type !== "Directory") return yield* new PathError({ path: input.path ?? ".", reason: "not_directory" })
|
||||||
return yield* fs.readDirectoryEntries(target.real).pipe(
|
return yield* fs.readDirectoryEntries(target.real).pipe(
|
||||||
Effect.orDie,
|
|
||||||
Effect.map((items) =>
|
Effect.map((items) =>
|
||||||
items
|
items
|
||||||
.flatMap((item) => {
|
.flatMap((item) => {
|
||||||
|
|||||||
@@ -21,3 +21,8 @@ export class Match extends Schema.Class<Match>("FileSystem.Match")({
|
|||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
submatches: Schema.Array(Submatch),
|
submatches: Schema.Array(Submatch),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
export class PathError extends Schema.TaggedErrorClass<PathError>()("FileSystem.PathError", {
|
||||||
|
path: Schema.String,
|
||||||
|
reason: Schema.Literals(["lexical_escape", "symlink_escape", "not_file", "not_directory"]),
|
||||||
|
}) {}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export * as Session from "./session"
|
export * as Session from "./session"
|
||||||
|
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import { EventV2 } from "../event"
|
|
||||||
import { ModelV2 } from "../model"
|
import { ModelV2 } from "../model"
|
||||||
import { SessionV2 } from "../session"
|
import { SessionV2 } from "../session"
|
||||||
import { MessageDecodeError } from "../session/error"
|
import { MessageDecodeError } from "../session/error"
|
||||||
@@ -34,9 +33,7 @@ export type Delivery = SessionInput.Delivery
|
|||||||
export const ListInput = SessionV2.ListInput
|
export const ListInput = SessionV2.ListInput
|
||||||
export type ListInput = SessionV2.ListInput
|
export type ListInput = SessionV2.ListInput
|
||||||
|
|
||||||
export const EventCursor = EventV2.Cursor
|
export type Event = SessionEvent.DurableEvent
|
||||||
export type EventCursor = EventV2.Cursor
|
|
||||||
export type Event = EventV2.CursorEvent<SessionEvent.DurableEvent>
|
|
||||||
|
|
||||||
export const NotFoundError = SessionV2.NotFoundError
|
export const NotFoundError = SessionV2.NotFoundError
|
||||||
export type NotFoundError = SessionV2.NotFoundError
|
export type NotFoundError = SessionV2.NotFoundError
|
||||||
@@ -99,7 +96,7 @@ export interface MessageInput {
|
|||||||
|
|
||||||
export interface EventsInput {
|
export interface EventsInput {
|
||||||
readonly sessionID: ID
|
readonly sessionID: ID
|
||||||
readonly after?: EventCursor
|
readonly after?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
|||||||
@@ -124,8 +124,8 @@ export interface Interface {
|
|||||||
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||||
readonly events: (input: {
|
readonly events: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
after?: EventV2.Cursor
|
after?: number
|
||||||
}) => Stream.Stream<EventV2.CursorEvent<SessionEvent.DurableEvent>, NotFoundError>
|
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
||||||
readonly switchAgent: (input: {
|
readonly switchAgent: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
agent: string
|
agent: string
|
||||||
@@ -339,12 +339,8 @@ export const layer = Layer.effect(
|
|||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
result
|
result
|
||||||
.get(input.sessionID)
|
.get(input.sessionID)
|
||||||
.pipe(Effect.as(events.aggregateEvents({ aggregateID: input.sessionID, after: input.after }))),
|
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
||||||
).pipe(
|
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
||||||
Stream.filter((event): event is EventV2.CursorEvent<SessionEvent.DurableEvent> =>
|
|
||||||
isDurableSessionEvent(event.event),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||||
Effect.uninterruptible(
|
Effect.uninterruptible(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -413,9 +409,9 @@ export const layer = Layer.effect(
|
|||||||
sessionID,
|
sessionID,
|
||||||
timestamp: yield* DateTime.now,
|
timestamp: yield* DateTime.now,
|
||||||
})
|
})
|
||||||
if (event.seq === undefined)
|
if (event.durable === undefined)
|
||||||
return yield* Effect.die("Interrupt request event is missing aggregate sequence")
|
return yield* Effect.die("Interrupt request event is missing aggregate sequence")
|
||||||
yield* execution.interrupt(sessionID, event.seq)
|
yield* execution.interrupt(sessionID, event.durable.seq)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ const Base = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
sync: {
|
durable: {
|
||||||
aggregate: "sessionID",
|
aggregate: "sessionID",
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
const stepSettlementOptions = {
|
const stepSettlementOptions = {
|
||||||
sync: {
|
durable: {
|
||||||
aggregate: "sessionID",
|
aggregate: "sessionID",
|
||||||
version: 2,
|
version: 2,
|
||||||
},
|
},
|
||||||
@@ -456,7 +456,7 @@ export namespace Compaction {
|
|||||||
|
|
||||||
export const Ended = EventV2.define({
|
export const Ended = EventV2.define({
|
||||||
type: "session.next.compaction.ended",
|
type: "session.next.compaction.ended",
|
||||||
sync: { aggregate: "sessionID", version: 2 },
|
durable: { aggregate: "sessionID", version: 2 },
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
messageID: SessionMessageID.ID,
|
messageID: SessionMessageID.ID,
|
||||||
|
|||||||
@@ -74,11 +74,11 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
|||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.flatMap((event) =>
|
Effect.flatMap((event) =>
|
||||||
event.seq === undefined
|
event.durable === undefined
|
||||||
? Effect.die("Prompt admission event is missing aggregate sequence")
|
? Effect.die("Prompt admission event is missing aggregate sequence")
|
||||||
: Effect.succeed(
|
: Effect.succeed(
|
||||||
new Admitted({
|
new Admitted({
|
||||||
admittedSeq: event.seq,
|
admittedSeq: event.durable.seq,
|
||||||
id: input.id,
|
id: input.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
@@ -117,13 +117,6 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
|
|||||||
readonly timeCreated: DateTime.Utc
|
readonly timeCreated: DateTime.Utc
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const message = yield* db
|
|
||||||
.select({ id: SessionMessageTable.id })
|
|
||||||
.from(SessionMessageTable)
|
|
||||||
.where(eq(SessionMessageTable.id, input.id))
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (message) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
|
||||||
const stored = yield* db
|
const stored = yield* db
|
||||||
.insert(SessionInputTable)
|
.insert(SessionInputTable)
|
||||||
.values({
|
.values({
|
||||||
@@ -208,37 +201,6 @@ const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionS
|
|||||||
input.sessionID === expected.sessionID &&
|
input.sessionID === expected.sessionID &&
|
||||||
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
|
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
|
||||||
|
|
||||||
export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
event: EventV2.Payload,
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
Schema.is(SessionEvent.PromptLifecycle.Admitted)(event) ||
|
|
||||||
Schema.is(SessionEvent.PromptLifecycle.Promoted)(event)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
const id = reservedID(event)
|
|
||||||
if (id === undefined) return
|
|
||||||
const admitted = yield* db
|
|
||||||
.select({ id: SessionInputTable.id })
|
|
||||||
.from(SessionInputTable)
|
|
||||||
.where(eq(SessionInputTable.id, id))
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (admitted === undefined) return
|
|
||||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
|
||||||
})
|
|
||||||
|
|
||||||
const reservedID = (event: EventV2.Payload) => {
|
|
||||||
if (Schema.is(SessionEvent.Step.Started)(event)) return event.data.assistantMessageID
|
|
||||||
if (Schema.is(SessionEvent.AgentSwitched)(event)) return event.data.messageID
|
|
||||||
if (Schema.is(SessionEvent.ModelSwitched)(event)) return event.data.messageID
|
|
||||||
if (Schema.is(SessionEvent.Prompted)(event)) return event.data.messageID
|
|
||||||
if (Schema.is(SessionEvent.Synthetic)(event)) return event.data.messageID
|
|
||||||
if (Schema.is(SessionEvent.Shell.Started)(event)) return event.data.messageID
|
|
||||||
if (Schema.is(SessionEvent.Compaction.Started)(event)) return event.data.messageID
|
|
||||||
}
|
|
||||||
|
|
||||||
export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* (
|
export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
|
|||||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||||
const updateMessage = (message: SessionMessage.Message) => {
|
const updateMessage = (message: SessionMessage.Message) => {
|
||||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
const encoded = encodeMessage(message)
|
const encoded = encodeMessage(message)
|
||||||
const { id, type, ...data } = encoded
|
const { id, type, ...data } = encoded
|
||||||
return db
|
return db
|
||||||
@@ -192,7 +192,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) {
|
function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) {
|
||||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
const encoded = encodeMessage(message)
|
const encoded = encodeMessage(message)
|
||||||
const { id, type, ...data } = encoded
|
const { id, type, ...data } = encoded
|
||||||
return db
|
return db
|
||||||
@@ -201,7 +201,7 @@ function insertMessage(db: DatabaseService, event: SessionEvent.Event, message:
|
|||||||
id: SessionMessage.ID.make(id),
|
id: SessionMessage.ID.make(id),
|
||||||
session_id: event.data.sessionID,
|
session_id: event.data.sessionID,
|
||||||
type,
|
type,
|
||||||
seq: event.seq,
|
seq: event.durable.seq,
|
||||||
time_created: DateTime.toEpochMillis(message.time.created),
|
time_created: DateTime.toEpochMillis(message.time.created),
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
@@ -213,7 +213,6 @@ export const layer = Layer.effectDiscard(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
yield* events.beforeCommit((event) => SessionInput.guardReservedID(db, event))
|
|
||||||
yield* events.project(SessionV1.Event.Created, (event) =>
|
yield* events.project(SessionV1.Event.Created, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const stored = yield* db
|
const stored = yield* db
|
||||||
@@ -331,7 +330,7 @@ export const layer = Layer.effectDiscard(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.AgentSwitched, (event) => {
|
yield* events.project(SessionEvent.AgentSwitched, (event) => {
|
||||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
return db
|
return db
|
||||||
.update(SessionTable)
|
.update(SessionTable)
|
||||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||||
@@ -340,7 +339,7 @@ export const layer = Layer.effectDiscard(
|
|||||||
.pipe(
|
.pipe(
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
Effect.andThen(run(db, event)),
|
Effect.andThen(run(db, event)),
|
||||||
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
|
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
yield* events.project(SessionEvent.ModelSwitched, (event) =>
|
yield* events.project(SessionEvent.ModelSwitched, (event) =>
|
||||||
@@ -352,9 +351,8 @@ export const layer = Layer.effectDiscard(
|
|||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
yield* run(db, event)
|
yield* run(db, event)
|
||||||
if (event.seq === undefined)
|
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)
|
||||||
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.Prompted, (event) =>
|
yield* events.project(SessionEvent.Prompted, (event) =>
|
||||||
@@ -368,24 +366,22 @@ export const layer = Layer.effectDiscard(
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (existing) return yield* Effect.die(new PromptAlreadyProjected())
|
if (existing) return yield* Effect.die(new PromptAlreadyProjected())
|
||||||
yield* run(db, event)
|
yield* run(db, event)
|
||||||
if (event.seq === undefined)
|
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
|
||||||
yield* SessionInput.projectLegacyPrompted(db, {
|
yield* SessionInput.projectLegacyPrompted(db, {
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
prompt: event.data.prompt,
|
prompt: event.data.prompt,
|
||||||
delivery: event.data.delivery,
|
delivery: event.data.delivery,
|
||||||
timeCreated: event.data.timestamp,
|
timeCreated: event.data.timestamp,
|
||||||
promotedSeq: event.seq,
|
promotedSeq: event.durable.seq,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) =>
|
yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.seq === undefined)
|
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
|
||||||
yield* SessionInput.projectAdmitted(db, {
|
yield* SessionInput.projectAdmitted(db, {
|
||||||
admittedSeq: event.seq,
|
admittedSeq: event.durable.seq,
|
||||||
id: event.data.messageID,
|
id: event.data.messageID,
|
||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
prompt: event.data.prompt,
|
prompt: event.data.prompt,
|
||||||
@@ -396,8 +392,7 @@ export const layer = Layer.effectDiscard(
|
|||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) =>
|
yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.seq === undefined)
|
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
|
||||||
yield* insertMessage(
|
yield* insertMessage(
|
||||||
db,
|
db,
|
||||||
event,
|
event,
|
||||||
@@ -406,18 +401,14 @@ export const layer = Layer.effectDiscard(
|
|||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
prompt: event.data.prompt,
|
prompt: event.data.prompt,
|
||||||
timeCreated: event.data.timeCreated,
|
timeCreated: event.data.timeCreated,
|
||||||
promotedSeq: event.seq,
|
promotedSeq: event.durable.seq,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.InterruptRequested, () => Effect.void)
|
yield* events.project(SessionEvent.InterruptRequested, () => Effect.void)
|
||||||
yield* events.project(SessionEvent.ContextUpdated, (event) => {
|
// TODO: Reconstruct context epoch replacement state during replay without adding replay state to every EventV2 payload.
|
||||||
if (!event.replay || event.seq === undefined) return run(db, event)
|
yield* events.project(SessionEvent.ContextUpdated, (event) => 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.Synthetic, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
||||||
@@ -436,9 +427,9 @@ export const layer = Layer.effectDiscard(
|
|||||||
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
|
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
|
||||||
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
|
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => {
|
yield* events.project(SessionEvent.Compaction.Ended, (event) => {
|
||||||
if (event.version === 1) return Effect.void
|
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
const seq = event.seq
|
if (event.durable.version === 1) return Effect.void
|
||||||
if (seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
const seq = event.durable.seq
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
yield* run(db, event)
|
yield* run(db, event)
|
||||||
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq)
|
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq)
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ export type WithParts = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
sync: {
|
durable: {
|
||||||
aggregate: "sessionID",
|
aggregate: "sessionID",
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const Message = EventV2.define({
|
|||||||
|
|
||||||
const SyncMessage = EventV2.define({
|
const SyncMessage = EventV2.define({
|
||||||
type: "test.sync",
|
type: "test.sync",
|
||||||
sync: {
|
durable: {
|
||||||
version: 1,
|
version: 1,
|
||||||
aggregate: "id",
|
aggregate: "id",
|
||||||
},
|
},
|
||||||
@@ -42,7 +42,7 @@ const SyncMessage = EventV2.define({
|
|||||||
|
|
||||||
const SyncSent = EventV2.define({
|
const SyncSent = EventV2.define({
|
||||||
type: "test.sent",
|
type: "test.sent",
|
||||||
sync: {
|
durable: {
|
||||||
version: 1,
|
version: 1,
|
||||||
aggregate: "messageID",
|
aggregate: "messageID",
|
||||||
},
|
},
|
||||||
@@ -61,7 +61,7 @@ const GlobalMessage = EventV2.define({
|
|||||||
|
|
||||||
const VersionedMessage = EventV2.define({
|
const VersionedMessage = EventV2.define({
|
||||||
type: "test.versioned",
|
type: "test.versioned",
|
||||||
sync: {
|
durable: {
|
||||||
version: 2,
|
version: 2,
|
||||||
aggregate: "id",
|
aggregate: "id",
|
||||||
},
|
},
|
||||||
@@ -73,7 +73,7 @@ const VersionedMessage = EventV2.define({
|
|||||||
|
|
||||||
const SyncTimestamp = EventV2.define({
|
const SyncTimestamp = EventV2.define({
|
||||||
type: "test.timestamp",
|
type: "test.timestamp",
|
||||||
sync: {
|
durable: {
|
||||||
version: 1,
|
version: 1,
|
||||||
aggregate: "id",
|
aggregate: "id",
|
||||||
},
|
},
|
||||||
@@ -132,7 +132,7 @@ describe("EventV2", () => {
|
|||||||
const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" })
|
const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" })
|
||||||
|
|
||||||
expect(event.type).toBe("test.versioned")
|
expect(event.type).toBe("test.versioned")
|
||||||
expect(event.version).toBe(2)
|
expect(event.durable?.version).toBe(2)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -146,12 +146,12 @@ describe("EventV2", () => {
|
|||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const latest = EventV2.define({
|
const latest = EventV2.define({
|
||||||
type: "test.out-of-order",
|
type: "test.out-of-order",
|
||||||
sync: { version: 2, aggregate: "id" },
|
durable: { version: 2, aggregate: "id" },
|
||||||
schema: { id: Schema.String },
|
schema: { id: Schema.String },
|
||||||
})
|
})
|
||||||
EventV2.define({
|
EventV2.define({
|
||||||
type: "test.out-of-order",
|
type: "test.out-of-order",
|
||||||
sync: { version: 1, aggregate: "id" },
|
durable: { version: 1, aggregate: "id" },
|
||||||
schema: { id: Schema.String },
|
schema: { id: Schema.String },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("commits local operational state inside a new synchronized event transaction", () =>
|
it.effect("commits local operational state inside a new durable event transaction", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const received = new Array<string>()
|
const received = new Array<string>()
|
||||||
@@ -207,7 +207,7 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rolls back the synchronized event and projector when the local commit fails", () =>
|
it.effect("rolls back the durable event and projector when the local commit fails", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
@@ -236,7 +236,7 @@ describe("EventV2", () => {
|
|||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const exit = yield* events.publish(Message, { text: "hello" }, { commit: () => Effect.void }).pipe(Effect.exit)
|
const exit = yield* events.publish(Message, { text: "hello" }, { commit: () => Effect.void }).pipe(Effect.exit)
|
||||||
|
|
||||||
expect(String(exit)).toContain("Local commit hooks require a synchronized event")
|
expect(String(exit)).toContain("Local commit hooks require a durable event")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -290,7 +290,6 @@ describe("EventV2", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const received = new Array<string>()
|
const received = new Array<string>()
|
||||||
yield* events.sync(() => Effect.die("sync defect"))
|
|
||||||
yield* events.listen(() => {
|
yield* events.listen(() => {
|
||||||
throw new Error("listener defect")
|
throw new Error("listener defect")
|
||||||
})
|
})
|
||||||
@@ -303,7 +302,7 @@ describe("EventV2", () => {
|
|||||||
const event = yield* events.publish(SyncMessage, { id: "one", text: "hello" })
|
const event = yield* events.publish(SyncMessage, { id: "one", text: "hello" })
|
||||||
|
|
||||||
expect(received).toEqual([SyncMessage.type])
|
expect(received).toEqual([SyncMessage.type])
|
||||||
expect(event.seq).toBeNumber()
|
expect(event.durable?.seq).toBeNumber()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -336,49 +335,7 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("does not synchronize live-only events", () =>
|
it.effect("inserts durable event rows on publish", () =>
|
||||||
Effect.gen(function* () {
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const synchronized = new Array<string>()
|
|
||||||
const unsubscribe = yield* events.sync((event) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
synchronized.push(event.type)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
yield* Effect.addFinalizer(() => unsubscribe)
|
|
||||||
|
|
||||||
yield* events.publish(Message, { text: "live only" })
|
|
||||||
yield* events.publish(SyncMessage, { id: "one", text: "durable" })
|
|
||||||
|
|
||||||
expect(synchronized).toEqual([SyncMessage.type])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("synchronizes only after the durable event commits", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
const synchronized = new Array<boolean>()
|
|
||||||
yield* events.sync((event) =>
|
|
||||||
db
|
|
||||||
.select({ id: EventTable.id })
|
|
||||||
.from(EventTable)
|
|
||||||
.where(eq(EventTable.id, event.id))
|
|
||||||
.get()
|
|
||||||
.pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
Effect.map((row) => synchronized.push(row !== undefined)),
|
|
||||||
Effect.asVoid,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* events.publish(SyncMessage, { id: EventV2.ID.create(), text: "durable" })
|
|
||||||
|
|
||||||
expect(synchronized).toEqual([true])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("inserts sync event rows on publish", () =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
@@ -398,7 +355,7 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("increments sync event seq per aggregate", () =>
|
it.effect("increments durable event seq per aggregate", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
@@ -417,22 +374,22 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("replays durable aggregate events after a cursor and tails new events", () =>
|
it.effect("replays durable aggregate events after a sequence and tails new events", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const aggregateID = EventV2.ID.create()
|
const aggregateID = EventV2.ID.create()
|
||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
|
||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
|
||||||
const fiber = yield* events
|
const fiber = yield* events
|
||||||
.aggregateEvents({ aggregateID, after: EventV2.Cursor.make(0) })
|
.durable({ aggregateID, after: 0 })
|
||||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "two" })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: "two" })
|
||||||
|
|
||||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([
|
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||||
[EventV2.Cursor.make(1), { id: aggregateID, text: "one" }],
|
[1, { id: aggregateID, text: "one" }],
|
||||||
[EventV2.Cursor.make(2), { id: aggregateID, text: "two" }],
|
[2, { id: aggregateID, text: "two" }],
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -442,20 +399,18 @@ describe("EventV2", () => {
|
|||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const aggregateID = EventV2.ID.create()
|
const aggregateID = EventV2.ID.create()
|
||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
|
||||||
const fiber = yield* events
|
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||||
.aggregateEvents({ aggregateID })
|
|
||||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
|
||||||
|
|
||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
Array.from(yield* Fiber.join(fiber)).map((event) => [
|
Array.from(yield* Fiber.join(fiber)).map((event) => [
|
||||||
event.cursor,
|
event.durable?.seq,
|
||||||
(event.event.data as { text: string }).text,
|
(event.data as { text: string }).text,
|
||||||
]),
|
]),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
[EventV2.Cursor.make(0), "zero"],
|
[0, "zero"],
|
||||||
[EventV2.Cursor.make(1), "one"],
|
[1, "one"],
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -476,17 +431,15 @@ describe("EventV2", () => {
|
|||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const aggregateID = EventV2.ID.create()
|
const aggregateID = EventV2.ID.create()
|
||||||
const fiber = yield* events
|
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
.aggregateEvents({ aggregateID })
|
|
||||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
|
||||||
yield* Deferred.await(readStarted)
|
yield* Deferred.await(readStarted)
|
||||||
|
|
||||||
pause = false
|
pause = false
|
||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "during handoff" })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: "during handoff" })
|
||||||
yield* Deferred.succeed(continueRead, undefined)
|
yield* Deferred.succeed(continueRead, undefined)
|
||||||
|
|
||||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([
|
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||||
[EventV2.Cursor.make(0), { id: aggregateID, text: "during handoff" }],
|
[0, { id: aggregateID, text: "during handoff" }],
|
||||||
])
|
])
|
||||||
}).pipe(Effect.provide(Layer.mergeAll(database, eventLayer)))
|
}).pipe(Effect.provide(Layer.mergeAll(database, eventLayer)))
|
||||||
}),
|
}),
|
||||||
@@ -498,7 +451,7 @@ describe("EventV2", () => {
|
|||||||
const aggregateID = EventV2.ID.create()
|
const aggregateID = EventV2.ID.create()
|
||||||
const count = 64
|
const count = 64
|
||||||
const fiber = yield* events
|
const fiber = yield* events
|
||||||
.aggregateEvents({ aggregateID })
|
.durable({ aggregateID })
|
||||||
.pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
|
.pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
@@ -506,11 +459,8 @@ describe("EventV2", () => {
|
|||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: String(index) })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: String(index) })
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual(
|
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
|
||||||
Array.from({ length: count }, (_, index) => [
|
Array.from({ length: count }, (_, index) => [index, { id: aggregateID, text: String(index) }]),
|
||||||
EventV2.Cursor.make(index),
|
|
||||||
{ id: aggregateID, text: String(index) },
|
|
||||||
]),
|
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -519,15 +469,13 @@ describe("EventV2", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const aggregateID = EventV2.ID.create()
|
const aggregateID = EventV2.ID.create()
|
||||||
const fiber = yield* events
|
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
.aggregateEvents({ aggregateID })
|
|
||||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
yield* events.publish(Message, { text: "live only" })
|
yield* events.publish(Message, { text: "live only" })
|
||||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "durable" })
|
yield* events.publish(SyncMessage, { id: aggregateID, text: "durable" })
|
||||||
|
|
||||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.event.type)).toEqual([SyncMessage.type])
|
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([SyncMessage.type])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -550,7 +498,7 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("replays sync events through projectors", () =>
|
it.effect("replays durable events through projectors", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const received = new Array<EventV2.Payload>()
|
const received = new Array<EventV2.Payload>()
|
||||||
@@ -706,7 +654,7 @@ describe("EventV2", () => {
|
|||||||
})
|
})
|
||||||
.pipe(Effect.exit)
|
.pipe(Effect.exit)
|
||||||
|
|
||||||
expect(String(exit)).toContain("Unknown sync event type")
|
expect(String(exit)).toContain("Unknown durable event type")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -843,7 +791,7 @@ describe("EventV2", () => {
|
|||||||
const replayed = {
|
const replayed = {
|
||||||
id: published.id,
|
id: published.id,
|
||||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||||
seq: published.seq!,
|
seq: published.durable!.seq,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
data: published.data,
|
data: published.data,
|
||||||
}
|
}
|
||||||
@@ -988,7 +936,7 @@ describe("EventV2", () => {
|
|||||||
yield* events.replay(replayed, { publish: true })
|
yield* events.replay(replayed, { publish: true })
|
||||||
yield* events.replay(replayed, { publish: true })
|
yield* events.replay(replayed, { publish: true })
|
||||||
|
|
||||||
expect(received).toMatchObject([{ id: replayed.id, seq: 0, data: replayed.data }])
|
expect(received).toMatchObject([{ id: replayed.id, durable: { seq: 0, version: 1 }, data: replayed.data }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1110,7 +1058,7 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("remove clears sync event sequence", () =>
|
it.effect("remove clears durable event sequence", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const received = new Array<EventV2.Payload>()
|
const received = new Array<EventV2.Payload>()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Exit, Layer } from "effect"
|
import { Cause, Effect, Exit, Layer } from "effect"
|
||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
@@ -31,6 +31,13 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
|||||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||||
|
|
||||||
describe("FileSystem", () => {
|
describe("FileSystem", () => {
|
||||||
|
const expectFail = (exit: Exit.Exit<unknown, unknown>) => {
|
||||||
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
if (Exit.isSuccess(exit)) return
|
||||||
|
expect(Cause.hasFails(exit.cause)).toBe(true)
|
||||||
|
expect(Cause.hasDies(exit.cause)).toBe(false)
|
||||||
|
}
|
||||||
|
|
||||||
it.live("reads text and binary files", () =>
|
it.live("reads text and binary files", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -60,13 +67,39 @@ describe("FileSystem", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("rejects lexical escapes", () =>
|
it.live("fails for missing paths", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const result = yield* (yield* FileSystem.Service)
|
const exit = yield* (yield* FileSystem.Service)
|
||||||
.read({ path: RelativePath.make("../outside.txt") })
|
.read({ path: RelativePath.make("missing.txt") })
|
||||||
.pipe(Effect.exit)
|
.pipe(Effect.exit)
|
||||||
expect(Exit.isFailure(result)).toBe(true)
|
expectFail(exit)
|
||||||
|
}).pipe(provide(directory)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("fails for wrong path kinds", () =>
|
||||||
|
withTmp((directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
|
||||||
|
const service = yield* FileSystem.Service
|
||||||
|
expectFail(yield* service.read({ path: RelativePath.make("src") }).pipe(Effect.exit))
|
||||||
|
expectFail(yield* service.list({ path: RelativePath.make("README.md") }).pipe(Effect.exit))
|
||||||
|
}).pipe(provide(directory)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("fails for lexical and symlink escapes", () =>
|
||||||
|
withTmp((directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const outside = path.join(directory, "..", "outside.txt")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(outside, "outside"))
|
||||||
|
yield* Effect.promise(() => fs.symlink(outside, path.join(directory, "linked.txt")))
|
||||||
|
const service = yield* FileSystem.Service
|
||||||
|
expectFail(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit))
|
||||||
|
expectFail(yield* service.read({ path: RelativePath.make("linked.txt") }).pipe(Effect.exit))
|
||||||
|
yield* Effect.promise(() => fs.rm(outside))
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -220,8 +220,8 @@ describe("SessionV2.create", () => {
|
|||||||
expect(
|
expect(
|
||||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
|
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
|
||||||
).toMatchObject([
|
).toMatchObject([
|
||||||
{ cursor: 1, event: { type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } } },
|
{ durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } },
|
||||||
{ cursor: 2, event: { type: "session.next.prompt.promoted" } },
|
{ durable: { seq: 2 }, type: "session.next.prompt.promoted" },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -355,7 +355,7 @@ describe("SessionV2.create", () => {
|
|||||||
expect(yield* session.get(created.id)).toMatchObject({ model })
|
expect(yield* session.get(created.id)).toMatchObject({ model })
|
||||||
expect(
|
expect(
|
||||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
|
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
|
||||||
).toMatchObject([{ event: { type: "session.next.model.switched", data: { model } } }])
|
).toMatchObject([{ type: "session.next.model.switched", data: { model } }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ describe("SessionProjector", () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
|
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
|
||||||
).toMatchObject({ promoted_seq: event.seq })
|
).toMatchObject({ promoted_seq: event.durable?.seq })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -334,134 +334,6 @@ describe("SessionProjector", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rejects a Prompted event that conflicts with an admitted inbox row", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
yield* db
|
|
||||||
.insert(ProjectTable)
|
|
||||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
yield* db
|
|
||||||
.insert(SessionTable)
|
|
||||||
.values({
|
|
||||||
id: sessionID,
|
|
||||||
project_id: Project.ID.global,
|
|
||||||
slug: "test",
|
|
||||||
directory: "/project",
|
|
||||||
title: "test",
|
|
||||||
version: "test",
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const id = SessionMessage.ID.make("msg_conflict")
|
|
||||||
yield* SessionInput.admit(db, events, {
|
|
||||||
id,
|
|
||||||
sessionID,
|
|
||||||
prompt: new Prompt({ text: "admitted" }),
|
|
||||||
delivery: "steer",
|
|
||||||
})
|
|
||||||
|
|
||||||
const exit = yield* events
|
|
||||||
.publish(SessionEvent.Prompted, {
|
|
||||||
sessionID,
|
|
||||||
messageID: id,
|
|
||||||
timestamp: created,
|
|
||||||
prompt: new Prompt({ text: "different" }),
|
|
||||||
delivery: "steer",
|
|
||||||
})
|
|
||||||
.pipe(Effect.exit)
|
|
||||||
|
|
||||||
expect(String(exit)).toContain("SessionInput.LifecycleConflict")
|
|
||||||
expect(
|
|
||||||
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
|
|
||||||
).toMatchObject({ promoted_seq: null })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("rejects an assistant message ID that conflicts with an admitted inbox row", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
yield* db
|
|
||||||
.insert(ProjectTable)
|
|
||||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
yield* db
|
|
||||||
.insert(SessionTable)
|
|
||||||
.values({
|
|
||||||
id: sessionID,
|
|
||||||
project_id: Project.ID.global,
|
|
||||||
slug: "test",
|
|
||||||
directory: "/project",
|
|
||||||
title: "test",
|
|
||||||
version: "test",
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const id = SessionMessage.ID.make("msg_conflict")
|
|
||||||
yield* SessionInput.admit(db, events, {
|
|
||||||
id,
|
|
||||||
sessionID,
|
|
||||||
prompt: new Prompt({ text: "admitted" }),
|
|
||||||
delivery: "steer",
|
|
||||||
})
|
|
||||||
|
|
||||||
const exit = yield* events
|
|
||||||
.publish(SessionEvent.Step.Started, {
|
|
||||||
sessionID,
|
|
||||||
timestamp: created,
|
|
||||||
assistantMessageID: id,
|
|
||||||
agent: "build",
|
|
||||||
model,
|
|
||||||
})
|
|
||||||
.pipe(Effect.exit)
|
|
||||||
|
|
||||||
expect(String(exit)).toContain("SessionInput.LifecycleConflict")
|
|
||||||
expect(
|
|
||||||
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
|
|
||||||
).toBeUndefined()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("rejects a Prompted delivery mode that conflicts with an admitted inbox row", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
yield* db
|
|
||||||
.insert(ProjectTable)
|
|
||||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
yield* db
|
|
||||||
.insert(SessionTable)
|
|
||||||
.values({
|
|
||||||
id: sessionID,
|
|
||||||
project_id: Project.ID.global,
|
|
||||||
slug: "test",
|
|
||||||
directory: "/project",
|
|
||||||
title: "test",
|
|
||||||
version: "test",
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const id = SessionMessage.ID.make("msg_delivery_conflict")
|
|
||||||
const prompt = new Prompt({ text: "admitted" })
|
|
||||||
yield* SessionInput.admit(db, events, { id, sessionID, prompt, delivery: "queue" })
|
|
||||||
|
|
||||||
const exit = yield* events
|
|
||||||
.publish(SessionEvent.Prompted, { sessionID, messageID: id, timestamp: created, prompt, delivery: "steer" })
|
|
||||||
.pipe(Effect.exit)
|
|
||||||
|
|
||||||
expect(String(exit)).toContain("SessionInput.LifecycleConflict")
|
|
||||||
expect(
|
|
||||||
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
|
|
||||||
).toMatchObject({ delivery: "queue", promoted_seq: null })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
|
it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const stale = new SessionMessage.Assistant({
|
const stale = new SessionMessage.Assistant({
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ describe("SessionV2.prompt", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("streams durable Session events after an aggregate cursor", () =>
|
it.effect("streams durable Session events after an aggregate sequence", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
@@ -191,17 +191,19 @@ describe("SessionV2.prompt", () => {
|
|||||||
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||||
const streamed = Array.from(yield* Fiber.join(fiber))
|
const streamed = Array.from(yield* Fiber.join(fiber))
|
||||||
|
|
||||||
expect(streamed.map((event) => [event.cursor, event.event.type])).toEqual([
|
expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([
|
||||||
[EventV2.Cursor.make(0), "session.next.prompt.admitted"],
|
[0, "session.next.prompt.admitted"],
|
||||||
[EventV2.Cursor.make(1), "session.next.prompt.admitted"],
|
[1, "session.next.prompt.admitted"],
|
||||||
[EventV2.Cursor.make(2), "session.next.prompt.promoted"],
|
[2, "session.next.prompt.promoted"],
|
||||||
[EventV2.Cursor.make(3), "session.next.prompt.promoted"],
|
[3, "session.next.prompt.promoted"],
|
||||||
])
|
])
|
||||||
expect(
|
expect(
|
||||||
Array.from(
|
Array.from(
|
||||||
yield* session.events({ sessionID, after: streamed[0]!.cursor }).pipe(Stream.take(1), Stream.runCollect),
|
yield* session
|
||||||
).map((event) => [event.cursor, event.event.type]),
|
.events({ sessionID, after: streamed[0]!.durable?.seq })
|
||||||
).toEqual([[EventV2.Cursor.make(1), "session.next.prompt.admitted"]])
|
.pipe(Stream.take(1), Stream.runCollect),
|
||||||
|
).map((event) => [event.durable?.seq, event.type]),
|
||||||
|
).toEqual([[1, "session.next.prompt.admitted"]])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -472,58 +474,6 @@ describe("SessionV2.prompt", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rejects an input ID already used by a durable non-prompt event", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* setup
|
|
||||||
const session = yield* SessionV2.Service
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
yield* events.publish(SessionEvent.Synthetic, {
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
timestamp: yield* DateTime.now,
|
|
||||||
text: "Collision",
|
|
||||||
})
|
|
||||||
|
|
||||||
const failure = yield* session
|
|
||||||
.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Collision" }), resume: false })
|
|
||||||
.pipe(Effect.flip)
|
|
||||||
|
|
||||||
expect(failure._tag).toBe("Session.PromptConflictError")
|
|
||||||
expect(yield* admitted(messageID)).toBeUndefined()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("rejects a durable event ID reserved by an admitted prompt without poisoning promotion", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* setup
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
const session = yield* SessionV2.Service
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const prompt = new Prompt({ text: "Reserved prompt" })
|
|
||||||
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
|
|
||||||
|
|
||||||
const failure = yield* events
|
|
||||||
.publish(SessionEvent.Synthetic, {
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
timestamp: yield* DateTime.now,
|
|
||||||
text: "Conflicting synthetic",
|
|
||||||
})
|
|
||||||
.pipe(Effect.catchDefect(Effect.succeed))
|
|
||||||
|
|
||||||
expect(String(failure)).toContain("SessionInput.LifecycleConflict")
|
|
||||||
expect(yield* admitted(messageID)).not.toHaveProperty("promotedSeq")
|
|
||||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
|
||||||
|
|
||||||
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
|
||||||
|
|
||||||
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
|
|
||||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
|
||||||
{ id: messageID, type: "user", text: "Reserved prompt" },
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("rejects reuse of one globally unique message ID across sessions", () =>
|
it.effect("rejects reuse of one globally unique message ID across sessions", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
|
|||||||
@@ -19,17 +19,17 @@ const capture = () => {
|
|||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload<typeof definition>
|
const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload<typeof definition>
|
||||||
published.push({
|
published.push({
|
||||||
type: definition.sync ? EventV2.versionedType(definition.type, definition.sync.version) : definition.type,
|
type: definition.durable
|
||||||
|
? EventV2.versionedType(definition.type, definition.durable.version)
|
||||||
|
: definition.type,
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
return event
|
return event
|
||||||
}),
|
}),
|
||||||
subscribe: () => Stream.empty,
|
subscribe: () => Stream.empty,
|
||||||
all: () => Stream.empty,
|
all: () => Stream.empty,
|
||||||
aggregateEvents: () => Stream.empty,
|
durable: () => Stream.empty,
|
||||||
sync: () => Effect.succeed(Effect.void),
|
|
||||||
listen: () => Effect.succeed(Effect.void),
|
listen: () => Effect.succeed(Effect.void),
|
||||||
beforeCommit: () => Effect.void,
|
|
||||||
project: () => Effect.void,
|
project: () => Effect.void,
|
||||||
replay: () => Effect.void,
|
replay: () => Effect.void,
|
||||||
replayAll: () => Effect.succeed(undefined),
|
replayAll: () => Effect.succeed(undefined),
|
||||||
|
|||||||
@@ -1355,34 +1355,6 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("replays retained context projections while replacement is pending", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* setup
|
|
||||||
const session = yield* SessionV2.Service
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
|
||||||
|
|
||||||
requests.length = 0
|
|
||||||
response = []
|
|
||||||
yield* session.resume(sessionID)
|
|
||||||
systemBaseline = "Changed context"
|
|
||||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
|
||||||
yield* session.resume(sessionID)
|
|
||||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
|
||||||
sessionID,
|
|
||||||
messageID: SessionMessage.ID.create(),
|
|
||||||
timestamp: DateTime.makeUnsafe(1),
|
|
||||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* replaySessionProjection(sessionID)
|
|
||||||
systemBaseline = "Replacement context"
|
|
||||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
|
|
||||||
yield* session.resume(sessionID)
|
|
||||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Replacement context"])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () =>
|
it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { effectCmd } from "../../effect-cmd"
|
import { CliError, effectCmd } from "../../effect-cmd"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
|
|
||||||
const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||||
@@ -13,6 +14,8 @@ const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|||||||
Effect.provide(LocationServiceMap.layer),
|
Effect.provide(LocationServiceMap.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const fileError = (error: FileSystem.PathError | FSUtil.Error) => new CliError({ message: error.message })
|
||||||
|
|
||||||
const FileSearchCommand = effectCmd({
|
const FileSearchCommand = effectCmd({
|
||||||
command: "search <query>",
|
command: "search <query>",
|
||||||
describe: "search files by query",
|
describe: "search files by query",
|
||||||
@@ -38,7 +41,9 @@ const FileReadCommand = effectCmd({
|
|||||||
description: "File path to read",
|
description: "File path to read",
|
||||||
}),
|
}),
|
||||||
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
|
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
|
||||||
const file = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })))
|
const file = yield* filesystem(
|
||||||
|
FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })),
|
||||||
|
).pipe(Effect.mapError(fileError))
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{ content: Buffer.from(file.content).toString("base64"), encoding: "base64", mime: file.mime },
|
{ content: Buffer.from(file.content).toString("base64"), encoding: "base64", mime: file.mime },
|
||||||
@@ -59,7 +64,9 @@ const FileListCommand = effectCmd({
|
|||||||
description: "File path to list",
|
description: "File path to list",
|
||||||
}),
|
}),
|
||||||
handler: Effect.fn("Cli.debug.file.list")(function* (args) {
|
handler: Effect.fn("Cli.debug.file.list")(function* (args) {
|
||||||
const files = yield* filesystem(FileSystem.Service.use((svc) => svc.list({ path: RelativePath.make(args.path) })))
|
const files = yield* filesystem(
|
||||||
|
FileSystem.Service.use((svc) => svc.list({ path: RelativePath.make(args.path) })),
|
||||||
|
).pipe(Effect.mapError(fileError))
|
||||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ export const layer = Layer.effect(
|
|||||||
workspace: workspaceID,
|
workspace: workspaceID,
|
||||||
payload: { id: event.id, type: event.type, properties: event.data },
|
payload: { id: event.id, type: event.type, properties: event.data },
|
||||||
})
|
})
|
||||||
const sync = EventV2.registry.get(event.type)?.sync
|
const durable = EventV2.registry.get(event.type)?.durable
|
||||||
if (sync === undefined || event.seq === undefined || event.version === undefined) return
|
if (durable === undefined || event.durable === undefined) return
|
||||||
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
|
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
|
||||||
if (typeof aggregateID !== "string") return
|
if (typeof aggregateID !== "string") return
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: event.location?.directory ?? ctx?.directory,
|
directory: event.location?.directory ?? ctx?.directory,
|
||||||
@@ -57,8 +57,8 @@ export const layer = Layer.effect(
|
|||||||
type: "sync",
|
type: "sync",
|
||||||
syncEvent: {
|
syncEvent: {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
type: EventV2.versionedType(event.type, event.version),
|
type: EventV2.versionedType(event.type, event.durable.version),
|
||||||
seq: event.seq,
|
seq: event.durable.seq,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
data: event.data,
|
data: event.data,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ const GlobalHealth = Schema.Struct({
|
|||||||
const SyncEventSchemas = EventV2.registry
|
const SyncEventSchemas = EventV2.registry
|
||||||
.values()
|
.values()
|
||||||
.flatMap((definition) => {
|
.flatMap((definition) => {
|
||||||
if (!definition.sync) return []
|
if (!definition.durable) return []
|
||||||
return [
|
return [
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.Literal("sync"),
|
type: Schema.Literal("sync"),
|
||||||
id: EventV2.ID,
|
id: EventV2.ID,
|
||||||
syncEvent: Schema.Struct({
|
syncEvent: Schema.Struct({
|
||||||
type: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)),
|
type: Schema.Literal(EventV2.versionedType(definition.type, definition.durable.version)),
|
||||||
id: EventV2.ID,
|
id: EventV2.ID,
|
||||||
seq: Schema.Finite,
|
seq: Schema.Finite,
|
||||||
aggregateID: Schema.String,
|
aggregateID: Schema.String,
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import ignore from "ignore"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { InstanceHttpApi } from "../api"
|
import { InstanceHttpApi } from "../api"
|
||||||
|
import { InvalidRequestError } from "../errors"
|
||||||
|
|
||||||
|
const invalidRequest = (error: FileSystem.PathError | FSUtil.Error) =>
|
||||||
|
new InvalidRequestError({ message: error.message, kind: error._tag })
|
||||||
|
|
||||||
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -79,7 +83,9 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
|||||||
.readFileString(path.join(location.project.directory, ".ignore"))
|
.readFileString(path.join(location.project.directory, ".ignore"))
|
||||||
.pipe(Effect.catch(() => Effect.succeed("")))
|
.pipe(Effect.catch(() => Effect.succeed("")))
|
||||||
if (ignorefile) ignored.add(ignorefile)
|
if (ignorefile) ignored.add(ignorefile)
|
||||||
return (yield* fs.list({ path: RelativePath.make(ctx.query.path) })).map((item) => ({
|
return (yield* fs
|
||||||
|
.list({ path: RelativePath.make(ctx.query.path) })
|
||||||
|
.pipe(Effect.mapError(invalidRequest))).map((item) => ({
|
||||||
name: path.basename(item.path),
|
name: path.basename(item.path),
|
||||||
path: item.path,
|
path: item.path,
|
||||||
absolute: path.resolve(location.directory, item.path),
|
absolute: path.resolve(location.directory, item.path),
|
||||||
@@ -101,6 +107,7 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
|||||||
return yield* filesystem(
|
return yield* filesystem(
|
||||||
FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })),
|
FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })),
|
||||||
).pipe(
|
).pipe(
|
||||||
|
Effect.mapError(invalidRequest),
|
||||||
Effect.flatMap((item) =>
|
Effect.flatMap((item) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const text = item.content.includes(0)
|
const text = item.content.includes(0)
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
import type { FileSystemEntry } from "@opencode-ai/sdk/v2/types"
|
import type { FileSystemEntry } from "@opencode-ai/sdk/v2/types"
|
||||||
import type { Effect } from "effect"
|
import type { Effect } from "effect"
|
||||||
|
import type { PlatformError } from "effect/PlatformError"
|
||||||
|
|
||||||
|
export type FileSystemError =
|
||||||
|
| PlatformError
|
||||||
|
| {
|
||||||
|
readonly _tag: "FileSystemError"
|
||||||
|
readonly method: string
|
||||||
|
readonly cause?: unknown
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly _tag: "FileSystem.PathError"
|
||||||
|
readonly path: string
|
||||||
|
readonly reason: "lexical_escape" | "symlink_escape" | "not_file" | "not_directory"
|
||||||
|
}
|
||||||
|
|
||||||
export interface FileSystem {
|
export interface FileSystem {
|
||||||
read(input: { readonly path: string }): Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
read(input: {
|
||||||
list(input?: { readonly path?: string }): Effect.Effect<FileSystemEntry[]>
|
readonly path: string
|
||||||
|
}): Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, FileSystemError>
|
||||||
|
list(input?: { readonly path?: string }): Effect.Effect<FileSystemEntry[], FileSystemError>
|
||||||
find(input: {
|
find(input: {
|
||||||
readonly query: string
|
readonly query: string
|
||||||
readonly type?: "file" | "directory"
|
readonly type?: "file" | "directory"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export type { AISDK, AISDKHooks } from "./aisdk.js"
|
|||||||
export type { Catalog, CatalogDraft, CatalogProviderRecord } from "./catalog.js"
|
export type { Catalog, CatalogDraft, CatalogProviderRecord } from "./catalog.js"
|
||||||
export type { Command, CommandDraft } from "./command.js"
|
export type { Command, CommandDraft } from "./command.js"
|
||||||
export type { Event, EventMap } from "./event.js"
|
export type { Event, EventMap } from "./event.js"
|
||||||
export type { FileSystem } from "./filesystem.js"
|
export type { FileSystem, FileSystemError } from "./filesystem.js"
|
||||||
export type { Integration, IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration } from "./integration.js"
|
export type { Integration, IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration } from "./integration.js"
|
||||||
export type { Location } from "./location.js"
|
export type { Location } from "./location.js"
|
||||||
export type { Npm } from "./npm.js"
|
export type { Npm } from "./npm.js"
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { RelativePath } from "@opencode-ai/core/schema"
|
import { RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpServerResponse } from "effect/unstable/http"
|
import { HttpServerResponse } from "effect/unstable/http"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { Api } from "../api"
|
import { Api } from "../api"
|
||||||
|
import { InvalidRequestError } from "../errors"
|
||||||
import { response } from "../groups/location"
|
import { response } from "../groups/location"
|
||||||
|
|
||||||
|
const invalidRequest = (error: FileSystem.PathError | FSUtil.Error) =>
|
||||||
|
new InvalidRequestError({ message: error.message, kind: error._tag })
|
||||||
|
|
||||||
export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
|
export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return handlers
|
return handlers
|
||||||
.handleRaw("fs.read", (ctx) =>
|
.handleRaw("fs.read", (ctx) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const file = yield* (yield* FileSystem.Service).read({
|
const file = yield* (yield* FileSystem.Service)
|
||||||
|
.read({
|
||||||
path: RelativePath.make(
|
path: RelativePath.make(
|
||||||
decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)),
|
decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
.pipe(Effect.mapError(invalidRequest))
|
||||||
return HttpServerResponse.uint8Array(file.content, { contentType: file.mime })
|
return HttpServerResponse.uint8Array(file.content, { contentType: file.mime })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -23,7 +30,7 @@ export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handler
|
|||||||
response(
|
response(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FileSystem.Service
|
const fs = yield* FileSystem.Service
|
||||||
return yield* fs.list(ctx.query)
|
return yield* fs.list(ctx.query).pipe(Effect.mapError(invalidRequest))
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2164,7 +2164,7 @@ function Read(props: ToolProps) {
|
|||||||
spinner={isRunning()}
|
spinner={isRunning()}
|
||||||
part={props.part}
|
part={props.part}
|
||||||
>
|
>
|
||||||
Read {pathFormatter.format(stringValue(props.input.filePath))}
|
Read {pathFormatter.format(stringValue(props.input.filePath))} {input(props.input, ["filePath"])}
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
<For each={loaded()}>
|
<For each={loaded()}>
|
||||||
{(filepath) => (
|
{(filepath) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user