@xandreed/plugin-session-sqlite 0.3.0 → 0.8.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/compatibility.adapter.d.ts +35 -0
- package/dist/compatibility.adapter.js +91 -0
- package/dist/compatibility.adapter.test.d.ts +1 -0
- package/dist/index.d.ts +17 -4
- package/dist/index.js +13 -54
- package/dist/legacy-session.adapter.d.ts +24 -0
- package/dist/legacy-session.adapter.js +80 -0
- package/dist/legacy-session.adapter.test.d.ts +1 -0
- package/dist/session-log.adapter.d.ts +50 -0
- package/dist/session-log.adapter.js +181 -0
- package/dist/session-log.adapter.test.d.ts +1 -0
- package/dist/store/sqliteStore.d.ts +20 -3
- package/dist/store/sqliteStore.js +98 -173
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Effect, Layer, Option } from "effect";
|
|
2
|
+
import { ConversationId, HarnessError, SessionEvent, SessionLog, SessionStore } from "@xandreed/core";
|
|
3
|
+
import type { EventBody, SessionCommitted, SessionDraft, SessionHead, SessionLogEvent } from "@xandreed/core";
|
|
4
|
+
type Log = SessionLog["Service"];
|
|
5
|
+
/** Read inherited records at their immutable fork boundary, then the child's own records. A fork at `through <= 0` inherits nothing. */
|
|
6
|
+
export declare const compatibilityHistory: (log: Log, id: ConversationId, kinds: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<SessionLogEvent>, HarnessError>;
|
|
7
|
+
/**
|
|
8
|
+
* The last event of `kinds` at or before `upTo` (the head when omitted), or
|
|
9
|
+
* the inherited history's when the session has none of its own: a binary
|
|
10
|
+
* search over the cursor, so an append never reads the whole history.
|
|
11
|
+
*/
|
|
12
|
+
export declare const compatibilityLast: (log: Log, id: ConversationId, kinds: ReadonlyArray<string>, upTo?: number) => Effect.Effect<Option.Option<SessionLogEvent>, HarnessError>;
|
|
13
|
+
/**
|
|
14
|
+
* Plan against the head's revision; a conflict re-reads and re-plans. The
|
|
15
|
+
* first retries are immediate, later ones wait a little (jittered), so an
|
|
16
|
+
* append outside a turn outlasts a busy turn writer instead of failing.
|
|
17
|
+
*/
|
|
18
|
+
export declare const compatibilityCommit: <A>(log: Log, id: ConversationId, plan: (head: SessionHead) => Effect.Effect<{
|
|
19
|
+
readonly drafts: ReadonlyArray<SessionDraft>;
|
|
20
|
+
readonly result: A;
|
|
21
|
+
}, HarnessError>) => Effect.Effect<{
|
|
22
|
+
readonly committed: SessionCommitted;
|
|
23
|
+
readonly result: A;
|
|
24
|
+
}, HarnessError>;
|
|
25
|
+
/** Includes branches, preserving the historical list contract. */
|
|
26
|
+
export declare const compatibilityListing: (log: Log, owner: string, parent?: Option.Option<string & import("effect/Brand").Brand<"ConversationId">>) => Effect.Effect<ReadonlyArray<SessionHead>, HarnessError>;
|
|
27
|
+
export declare const harnessHistory: (log: Log, id: ConversationId, after?: number) => Effect.Effect<ReadonlyArray<SessionEvent>, HarnessError>;
|
|
28
|
+
export declare const harnessEvent: (id: ConversationId, body: EventBody, seq: number, at: number) => SessionEvent;
|
|
29
|
+
/**
|
|
30
|
+
* @deprecated Historical harness vocabulary projected over SessionLog.
|
|
31
|
+
* It owns no tables and can be removed once custom AgentLoop hosts use the
|
|
32
|
+
* composable Agent/Turn API. Harness itself admits and ends through Sessions.
|
|
33
|
+
*/
|
|
34
|
+
export declare const SessionStoreProjectionLive: Layer.Layer<SessionStore, never, SessionLog>;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Clock, Effect, Layer, Option, Random, Schema } from "effect";
|
|
2
|
+
import { ConversationId, HarnessError, SessionEvent, SessionLog, SessionRecord, SessionStore } from "@xandreed/core";
|
|
3
|
+
const storageFailure = (error) => new HarnessError({ code: error._tag === "SessionMissing" ? "session.missing" : "session.log", message: error.message ?? error._tag }), eventOf = Schema.decodeUnknownEffect(SessionEvent), decodeFailure = (error) => new HarnessError({ code: "session.decode", message: String(error) }), HARNESS = "harness.event", SETTLED = ["run.completed", "run.failed", "run.cancelled"], harnessSeqOf = (event) => {
|
|
4
|
+
const inner = event.data.event;
|
|
5
|
+
return typeof inner === "object" && inner !== null && "seq" in inner && typeof inner.seq === "number" ? inner.seq : Number.POSITIVE_INFINITY;
|
|
6
|
+
};
|
|
7
|
+
export const compatibilityHistory = (log, id, kinds) => Effect.gen(function* () {
|
|
8
|
+
const head = yield* log.head(id).pipe(Effect.mapError(storageFailure)), inherited = yield* Option.match(head.header.parent, {
|
|
9
|
+
onNone: () => Effect.succeed([]),
|
|
10
|
+
onSome: (parent) => parent.through <= 0 ? Effect.succeed([]) : compatibilityHistory(log, parent.id, kinds).pipe(Effect.map((events) => events.filter((event) => event.session !== parent.id || event.seq <= parent.through)))
|
|
11
|
+
}), own = yield* log.read(id, { after: 0, limit: Option.none(), kinds }).pipe(Effect.mapError(storageFailure));
|
|
12
|
+
return [...inherited, ...own];
|
|
13
|
+
});
|
|
14
|
+
const nextOf = (log, id, kinds, after, upTo) => log.read(id, { after, limit: Option.some(1), kinds }).pipe(Effect.map((events) => Option.filter(Option.fromNullishOr(events[0]), (event) => event.seq <= upTo)), Effect.mapError(storageFailure)), firstHolding = (low, high, holds) => low >= high ? Effect.succeed(high) : Effect.gen(function* () {
|
|
15
|
+
const middle = Math.floor((low + high) / 2);
|
|
16
|
+
return (yield* holds(middle)) ? yield* firstHolding(low, middle, holds) : yield* firstHolding(middle + 1, high, holds);
|
|
17
|
+
});
|
|
18
|
+
export const compatibilityLast = (log, id, kinds, upTo = Number.MAX_SAFE_INTEGER) => Effect.gen(function* () {
|
|
19
|
+
const head = yield* log.head(id).pipe(Effect.mapError(storageFailure)), bound = Math.min(head.seq, upTo), cursor = yield* firstHolding(0, bound, (at) => nextOf(log, id, kinds, at, bound).pipe(Effect.map(Option.isNone)));
|
|
20
|
+
if (cursor > 0)
|
|
21
|
+
return yield* nextOf(log, id, kinds, cursor - 1, bound);
|
|
22
|
+
return yield* Option.match(head.header.parent, {
|
|
23
|
+
onNone: () => Effect.succeed(Option.none()),
|
|
24
|
+
onSome: (parent) => parent.through <= 0 ? Effect.succeed(Option.none()) : compatibilityLast(log, parent.id, kinds, parent.through)
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
const harnessAfter = (log, id, after, upTo = Number.MAX_SAFE_INTEGER) => Effect.gen(function* () {
|
|
28
|
+
const head = yield* log.head(id).pipe(Effect.mapError(storageFailure)), bound = Math.min(head.seq, upTo), beyond = (cursor) => nextOf(log, id, [HARNESS], cursor, bound).pipe(Effect.map(Option.match({ onNone: () => !0, onSome: (event) => harnessSeqOf(event) > after }))), cursor = after < 0 || (yield* beyond(0)) ? 0 : yield* firstHolding(1, bound, beyond), own = (yield* log.read(id, { after: cursor, limit: Option.none(), kinds: [HARNESS] }).pipe(Effect.mapError(storageFailure))).filter((event) => event.seq <= bound && harnessSeqOf(event) > after);
|
|
29
|
+
return [...cursor > 0 ? [] : yield* Option.match(head.header.parent, {
|
|
30
|
+
onNone: () => Effect.succeed([]),
|
|
31
|
+
onSome: (parent) => parent.through <= 0 ? Effect.succeed([]) : harnessAfter(log, parent.id, after, parent.through)
|
|
32
|
+
}), ...own];
|
|
33
|
+
});
|
|
34
|
+
export const compatibilityCommit = (log, id, plan) => {
|
|
35
|
+
const attempt = (tries) => Effect.gen(function* () {
|
|
36
|
+
const head = yield* log.head(id).pipe(Effect.mapError(storageFailure)), planned = yield* plan(head);
|
|
37
|
+
return yield* log.commit(id, { expect: head.revision, notAfter: Option.none(), state: Option.none(), events: planned.drafts }).pipe(Effect.map((committed) => ({ committed, result: planned.result })), Effect.catchTag("RevisionConflict", (conflict) => tries >= 32 ? Effect.fail(storageFailure(conflict)) : tries < 4 ? attempt(tries + 1) : Random.nextIntBetween(1, Math.min(2 ** (tries - 3), 50)).pipe(Effect.flatMap((wait) => Effect.sleep(`${wait} millis`)), Effect.andThen(attempt(tries + 1)))), Effect.mapError((error) => error instanceof HarnessError ? error : storageFailure(error)));
|
|
38
|
+
});
|
|
39
|
+
return attempt(0);
|
|
40
|
+
}, compatibilityListing = (log, owner, parent = Option.none()) => Effect.gen(function* () {
|
|
41
|
+
const heads = yield* log.list({ owner, limit: Number.MAX_SAFE_INTEGER, before: Option.none(), parent }).pipe(Effect.mapError(storageFailure)), children = yield* Effect.forEach(heads, (head) => compatibilityListing(log, owner, Option.some(head.header.id)));
|
|
42
|
+
return [...heads, ...children.flat()].sort((a, b) => b.header.createdAt - a.header.createdAt);
|
|
43
|
+
});
|
|
44
|
+
const recordOf = (head) => Schema.decodeUnknownEffect(SessionRecord)({
|
|
45
|
+
id: head.header.id,
|
|
46
|
+
workspace: head.header.owner,
|
|
47
|
+
profile: head.header.meta.profile ?? "smith",
|
|
48
|
+
createdAt: head.header.createdAt,
|
|
49
|
+
...Option.match(head.header.parent, { onNone: () => typeof head.header.meta.legacyParent === "string" ? { parent: head.header.meta.legacyParent } : {}, onSome: (parent) => ({ parent: parent.id }) })
|
|
50
|
+
}).pipe(Effect.mapError((error) => new HarnessError({ code: "session.decode", message: String(error) }))), decodedHarness = (id, events) => Effect.forEach(events, (event) => eventOf(event.data.event).pipe(Effect.map((decoded) => ({ ...decoded, sessionId: id })), Effect.mapError(decodeFailure)));
|
|
51
|
+
export const harnessHistory = (log, id, after = -1) => harnessAfter(log, id, after).pipe(Effect.flatMap((events) => decodedHarness(id, events))), harnessEvent = (id, body, seq, at) => ({ ...body, version: 1, id: crypto.randomUUID(), sessionId: id, seq, at });
|
|
52
|
+
const isHarness = (event) => event.kind === HARNESS;
|
|
53
|
+
export const SessionStoreProjectionLive = Layer.effect(SessionStore, Effect.gen(function* () {
|
|
54
|
+
const log = yield* SessionLog;
|
|
55
|
+
return SessionStore.of({
|
|
56
|
+
create: (workspace, profile) => Clock.currentTimeMillis.pipe(Effect.flatMap((createdAt) => log.create({
|
|
57
|
+
id: ConversationId.make(crypto.randomUUID()),
|
|
58
|
+
owner: workspace,
|
|
59
|
+
origin: "harness",
|
|
60
|
+
createdAt,
|
|
61
|
+
meta: { workspace, profile, projection: "harness" },
|
|
62
|
+
parent: Option.none()
|
|
63
|
+
})), Effect.flatMap(recordOf), Effect.mapError((error) => error instanceof HarnessError ? error : storageFailure(error))),
|
|
64
|
+
get: (id) => log.head(id).pipe(Effect.mapError(storageFailure), Effect.flatMap(recordOf)),
|
|
65
|
+
list: (workspace) => compatibilityListing(log, workspace).pipe(Effect.flatMap((heads) => Effect.forEach(heads.filter((head) => head.header.origin !== "task"), recordOf))),
|
|
66
|
+
read: (id, after) => harnessHistory(log, id, after),
|
|
67
|
+
append: (id, body) => compatibilityCommit(log, id, (head) => Effect.gen(function* () {
|
|
68
|
+
const last = yield* compatibilityLast(log, id, [HARNESS], head.seq), previous = yield* Option.match(last, {
|
|
69
|
+
onNone: () => Effect.succeed(-1),
|
|
70
|
+
onSome: (event) => eventOf(event.data.event).pipe(Effect.map((decoded) => decoded.seq), Effect.mapError(decodeFailure))
|
|
71
|
+
}), event = harnessEvent(id, body, previous + 1, yield* Clock.currentTimeMillis);
|
|
72
|
+
return { drafts: [{ kind: HARNESS, turn: Option.none(), data: { event } }], result: event };
|
|
73
|
+
})).pipe(Effect.map((done) => done.result)),
|
|
74
|
+
fork: (id, through) => Effect.gen(function* () {
|
|
75
|
+
const parent = yield* log.head(id).pipe(Effect.mapError(storageFailure));
|
|
76
|
+
if ((yield* harnessHistory(log, id)).filter((event) => event.seq <= through).reduce((runs, event) => event.runId === void 0 ? runs : event.name === "run.started" ? [...runs, event.runId] : SETTLED.includes(event.name) ? runs.filter((run) => run !== event.runId) : runs, []).length > 0)
|
|
77
|
+
return yield* Effect.fail(new HarnessError({ code: "session.fork-boundary", message: "Fork at a settled turn boundary" }));
|
|
78
|
+
const journal = yield* compatibilityHistory(log, id, []), lastIndex = journal.findLastIndex((event) => isHarness(event) && harnessSeqOf(event) <= through), endIndex = Option.flatMap(Option.fromNullishOr(journal[lastIndex]), (last) => Option.flatMap(Option.fromNullishOr(journal.slice(lastIndex + 1).find((event) => isHarness(event) || event.kind === "turn.ended" && event.session === last.session && Option.isSome(last.turn) && Option.contains(event.turn, last.turn.value))), (next) => isHarness(next) ? Option.none() : Option.some(journal.indexOf(next)))), boundaryIndex = Option.getOrElse(endIndex, () => lastIndex), inherited = journal.slice(0, boundaryIndex + 1), boundary = Option.fromNullishOr(journal[boundaryIndex]), inheritedCut = Option.exists(boundary, (event) => event.session !== id), copied = inheritedCut ? inherited.filter((event) => isHarness(event) || event.kind.startsWith("conversation.")) : [], turnAtFork = inherited.reduce((turns, event) => Option.match(event.turn, { onNone: () => turns, onSome: (turn) => Math.max(turns, turn) }), 0), created = yield* log.create({
|
|
79
|
+
id: ConversationId.make(crypto.randomUUID()),
|
|
80
|
+
owner: parent.header.owner,
|
|
81
|
+
origin: parent.header.origin,
|
|
82
|
+
createdAt: yield* Clock.currentTimeMillis,
|
|
83
|
+
meta: { ...parent.header.meta, legacyParent: id },
|
|
84
|
+
parent: Option.some({ id, through: inheritedCut ? 0 : Option.match(boundary, { onNone: () => 0, onSome: (event) => event.seq }), turnAtFork })
|
|
85
|
+
}).pipe(Effect.mapError(storageFailure));
|
|
86
|
+
if (copied.length > 0)
|
|
87
|
+
yield* compatibilityCommit(log, created.header.id, () => Effect.succeed({ drafts: copied.map((event) => ({ kind: event.kind, turn: Option.none(), data: event.data })), result: void 0 })).pipe(Effect.onError(() => log.remove(created.header.id).pipe(Effect.ignore)));
|
|
88
|
+
return yield* recordOf(created);
|
|
89
|
+
}).pipe(Effect.uninterruptible)
|
|
90
|
+
});
|
|
91
|
+
}));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { Layer } from "effect";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
export declare const
|
|
2
|
+
import { SessionEnvironment, SessionLog, SessionStore, TurnAdmission } from "@xandreed/core";
|
|
3
|
+
/** @deprecated The historical store API is a projection over the unified log. */
|
|
4
|
+
export declare const SessionStoreLive: (path: string, options?: {
|
|
5
|
+
readonly legacyPaths?: ReadonlyArray<string>;
|
|
6
|
+
readonly legacyOwner?: string;
|
|
7
|
+
}) => Layer.Layer<SessionLog | TurnAdmission | SessionStore, import("@xandreed/core").SessionLogError, never>;
|
|
8
|
+
/** Storage and the open admission policy; compose sessionsPlugin explicitly in the host graph. */
|
|
9
|
+
export declare const sessionSqlitePlugin: import("@xandreed/core").TypedPlugin<{
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly legacyPaths: readonly string[];
|
|
12
|
+
}, {
|
|
13
|
+
readonly path: string;
|
|
14
|
+
readonly legacyPaths: readonly string[];
|
|
15
|
+
}, SessionLog | TurnAdmission | SessionStore, import("@xandreed/core").SessionLogError, SessionEnvironment>;
|
|
5
16
|
export default sessionSqlitePlugin;
|
|
6
|
-
export { SqliteConversationStoreLive } from "./store/sqliteStore.js";
|
|
17
|
+
export { ConversationStoreProjectionLive, SqliteConversationStoreLive } from "./store/sqliteStore.js";
|
|
18
|
+
export { SessionStoreProjectionLive } from "./compatibility.adapter.js";
|
|
19
|
+
export { SessionLogSqliteLive, sessionLogSqlitePlugin } from "./session-log.adapter.js";
|
package/dist/index.js
CHANGED
|
@@ -1,60 +1,19 @@
|
|
|
1
|
-
import { Database } from "bun:sqlite";
|
|
2
|
-
import { mkdir } from "node:fs/promises";
|
|
3
|
-
import { dirname } from "node:path";
|
|
4
1
|
import { Effect, Layer, Schema } from "effect";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
yield* operation(() => db.exec(`
|
|
11
|
-
PRAGMA journal_mode = WAL;
|
|
12
|
-
PRAGMA busy_timeout = 5000;
|
|
13
|
-
PRAGMA foreign_keys = ON;
|
|
14
|
-
CREATE TABLE IF NOT EXISTS harness_sessions (id TEXT PRIMARY KEY, workspace TEXT NOT NULL, body TEXT NOT NULL);
|
|
15
|
-
CREATE TABLE IF NOT EXISTS harness_events (session_id TEXT NOT NULL REFERENCES harness_sessions(id), seq INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(session_id, seq));
|
|
16
|
-
`));
|
|
17
|
-
const insert = (record) => {
|
|
18
|
-
db.query("INSERT INTO harness_sessions VALUES (?, ?, ?)").run(record.id, record.workspace, JSON.stringify(record));
|
|
19
|
-
return record;
|
|
20
|
-
}, get = (id) => operation(() => db.query("SELECT body FROM harness_sessions WHERE id = ?").get(id)).pipe(Effect.flatMap((row) => row === null ? Effect.fail(new HarnessError({ code: "session.missing", message: `Session ${id} does not exist` })) : operation(() => decodeRecord(row.body)))), read = (id, after) => get(id).pipe(Effect.zipRight(operation(() => db.query("SELECT body FROM harness_events WHERE session_id = ? AND seq > ? ORDER BY seq").all(id, after).map((row) => decodeEvent(row.body)))));
|
|
21
|
-
return SessionStore.of({
|
|
22
|
-
create: (workspace, profile) => operation(() => insert({ id: ConversationId.make(crypto.randomUUID()), workspace, profile, createdAt: Date.now() })),
|
|
23
|
-
get,
|
|
24
|
-
list: (workspace) => operation(() => db.query("SELECT body FROM harness_sessions WHERE workspace = ? ORDER BY rowid DESC").all(workspace).map((row) => decodeRecord(row.body))),
|
|
25
|
-
read,
|
|
26
|
-
append: (id, body) => get(id).pipe(Effect.zipRight(operation(() => db.transaction(() => {
|
|
27
|
-
const row = db.query("SELECT COALESCE(MAX(seq), -1) + 1 AS seq FROM harness_events WHERE session_id = ?").get(id), event = { ...body, version: 1, id: crypto.randomUUID(), sessionId: id, seq: row?.seq ?? 0, at: Date.now() };
|
|
28
|
-
db.query("INSERT INTO harness_events VALUES (?, ?, ?)").run(id, event.seq, JSON.stringify(event));
|
|
29
|
-
return event;
|
|
30
|
-
})()))),
|
|
31
|
-
fork: (id, through) => Effect.gen(function* () {
|
|
32
|
-
const parent = yield* get(id), trail = (yield* read(id, -1)).filter((event) => event.seq <= through);
|
|
33
|
-
if (trail.reduce((runs, event) => {
|
|
34
|
-
if (event.runId === void 0)
|
|
35
|
-
return runs;
|
|
36
|
-
if (event.name === "run.started")
|
|
37
|
-
return [...runs, event.runId];
|
|
38
|
-
if (["run.completed", "run.failed", "run.cancelled"].includes(event.name))
|
|
39
|
-
return runs.filter((run) => run !== event.runId);
|
|
40
|
-
return runs;
|
|
41
|
-
}, []).length > 0)
|
|
42
|
-
return yield* Effect.fail(new HarnessError({ code: "session.fork-boundary", message: "Fork at a settled turn boundary" }));
|
|
43
|
-
return yield* operation(() => db.transaction(() => {
|
|
44
|
-
const record = insert({ ...parent, id: ConversationId.make(crypto.randomUUID()), parent: id, createdAt: Date.now() });
|
|
45
|
-
trail.forEach((event, seq) => db.query("INSERT INTO harness_events VALUES (?, ?, ?)").run(record.id, seq, JSON.stringify({ ...event, sessionId: record.id, id: crypto.randomUUID(), seq })));
|
|
46
|
-
return record;
|
|
47
|
-
})());
|
|
48
|
-
})
|
|
49
|
-
});
|
|
50
|
-
})), sessionSqlitePlugin = definePlugin({
|
|
2
|
+
import { definePlugin, SessionEnvironment, SessionLog, SessionStore, TurnAdmission, TurnAdmissionOpen } from "@xandreed/core";
|
|
3
|
+
import { SessionStoreProjectionLive } from "./compatibility.adapter.js";
|
|
4
|
+
import { SessionLogSqliteLive } from "./session-log.adapter.js";
|
|
5
|
+
const Config = Schema.Struct({ path: Schema.String, legacyPaths: Schema.Array(Schema.String) });
|
|
6
|
+
export const SessionStoreLive = (path, options = {}) => Layer.mergeAll(SessionStoreProjectionLive.pipe(Layer.provideMerge(SessionLogSqliteLive(path, options))), TurnAdmissionOpen), sessionSqlitePlugin = definePlugin({
|
|
51
7
|
id: "@xandreed/plugin-session-sqlite",
|
|
52
|
-
version: "0.
|
|
8
|
+
version: "0.8.0-next.0",
|
|
53
9
|
scope: "runtime",
|
|
54
10
|
config: Config,
|
|
55
|
-
defaults: { path: ".efferent/runtime/sessions.db" },
|
|
56
|
-
|
|
57
|
-
|
|
11
|
+
defaults: { path: ".efferent/runtime/sessions.db", legacyPaths: [] },
|
|
12
|
+
requires: [SessionEnvironment],
|
|
13
|
+
provides: [SessionLog, SessionStore, TurnAdmission],
|
|
14
|
+
layer: ({ path, legacyPaths }) => Layer.unwrap(SessionEnvironment.pipe(Effect.map(({ workspace }) => SessionStoreLive(path, { legacyPaths, legacyOwner: workspace }))))
|
|
58
15
|
});
|
|
59
16
|
export default sessionSqlitePlugin;
|
|
60
|
-
export { SqliteConversationStoreLive } from "./store/sqliteStore.js";
|
|
17
|
+
export { ConversationStoreProjectionLive, SqliteConversationStoreLive } from "./store/sqliteStore.js";
|
|
18
|
+
export { SessionStoreProjectionLive } from "./compatibility.adapter.js";
|
|
19
|
+
export { SessionLogSqliteLive, sessionLogSqlitePlugin } from "./session-log.adapter.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { Option } from "effect";
|
|
3
|
+
/** What importing one source did: refused (nothing written), or imported, with the rows it could not decode and skipped. */
|
|
4
|
+
export interface LegacyImport {
|
|
5
|
+
readonly refused: Option.Option<string>;
|
|
6
|
+
readonly skipped: ReadonlyArray<string>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The import bookkeeping: the sources already imported, and the sessions
|
|
10
|
+
* removed since (a removed session is never imported again, whatever path
|
|
11
|
+
* its source is found at).
|
|
12
|
+
*/
|
|
13
|
+
export declare const LEGACY_BOOKKEEPING = "\n CREATE TABLE IF NOT EXISTS session_log_imports (source TEXT PRIMARY KEY, imported_at INTEGER NOT NULL);\n CREATE TABLE IF NOT EXISTS session_log_tombstones (session_id TEXT PRIMARY KEY, removed_at INTEGER NOT NULL);\n";
|
|
14
|
+
/**
|
|
15
|
+
* Import frozen pre-log tables once, in the destination's IMMEDIATE
|
|
16
|
+
* transaction. Sources stay read only. A session is matched by id whatever
|
|
17
|
+
* path its source is found at: events it already holds are not imported
|
|
18
|
+
* again, a removed session is not imported again, and an orphan message row
|
|
19
|
+
* (written under a harness id with no conversation row) joins the session
|
|
20
|
+
* stored under its id, with that session's owner. A conflicting identity or
|
|
21
|
+
* position refuses the entire source before any row is changed; a row that
|
|
22
|
+
* does not decode is skipped and reported.
|
|
23
|
+
*/
|
|
24
|
+
export declare const importLegacySessions: (database: Database, source: Database, sourceId: string, owner?: Option.Option<string>) => LegacyImport;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { Option, Result, Schema } from "effect";
|
|
3
|
+
import { canonicalJson, SessionEvent, SessionRecord } from "@xandreed/core";
|
|
4
|
+
const decodeRecord = Schema.decodeUnknownResult(Schema.fromJsonString(SessionRecord)), decodeEvent = Schema.decodeUnknownResult(Schema.fromJsonString(SessionEvent)), hasTable = (database, name) => database.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) !== null;
|
|
5
|
+
export const LEGACY_BOOKKEEPING = `
|
|
6
|
+
CREATE TABLE IF NOT EXISTS session_log_imports (source TEXT PRIMARY KEY, imported_at INTEGER NOT NULL);
|
|
7
|
+
CREATE TABLE IF NOT EXISTS session_log_tombstones (session_id TEXT PRIMARY KEY, removed_at INTEGER NOT NULL);
|
|
8
|
+
`;
|
|
9
|
+
const HARNESS_KINDS = ["harness.event"], CONVERSATION_KINDS = ["conversation.message", "conversation.checkpoint", "conversation.title", "conversation.outcome"], placeOf = (event) => {
|
|
10
|
+
const inner = event.data.event;
|
|
11
|
+
if (event.kind === "conversation.message" && typeof event.data.position === "number")
|
|
12
|
+
return Option.some(`${event.kind}:${event.data.position}`);
|
|
13
|
+
return event.kind === "harness.event" && typeof inner === "object" && inner !== null && "seq" in inner && typeof inner.seq === "number" ? Option.some(`${event.kind}:${inner.seq}`) : Option.none();
|
|
14
|
+
};
|
|
15
|
+
export const importLegacySessions = (database, source, sourceId, owner = Option.none()) => database.transaction(() => {
|
|
16
|
+
database.exec(LEGACY_BOOKKEEPING);
|
|
17
|
+
if (database.query("SELECT source FROM session_log_imports WHERE source = ?").get(sourceId) !== null)
|
|
18
|
+
return { refused: Option.none(), skipped: [] };
|
|
19
|
+
const removed = (id) => database.query("SELECT session_id FROM session_log_tombstones WHERE session_id = ?").get(id) !== null, stored = (id) => Option.fromNullishOr(database.query("SELECT owner, origin, created_at FROM session_heads WHERE id = ?").get(id)), records = hasTable(source, "harness_sessions") ? source.query("SELECT id, body FROM harness_sessions ORDER BY rowid").all().map((row) => ({ row, decoded: decodeRecord(row.body) })) : [], harness = records.flatMap(({ decoded }) => Result.match(decoded, { onFailure: () => [], onSuccess: (record) => [record] })).map((record) => {
|
|
20
|
+
const rows = source.query("SELECT seq, body FROM harness_events WHERE session_id = ? ORDER BY seq").all(record.id).map((row) => ({ row, decoded: decodeEvent(row.body) }));
|
|
21
|
+
return { plan: {
|
|
22
|
+
id: record.id,
|
|
23
|
+
owner: record.workspace,
|
|
24
|
+
origin: "harness",
|
|
25
|
+
createdAt: record.createdAt,
|
|
26
|
+
kinds: HARNESS_KINDS,
|
|
27
|
+
meta: { workspace: record.workspace, profile: record.profile, projection: "harness", ...Option.match(Option.fromNullishOr(record.parent), { onNone: () => ({}), onSome: (parent) => ({ legacyParent: parent }) }) },
|
|
28
|
+
events: rows.flatMap(({ decoded }) => Result.match(decoded, { onFailure: () => [], onSuccess: (event) => [{ kind: "harness.event", at: event.at, data: { event } }] }))
|
|
29
|
+
}, skipped: rows.flatMap(({ row, decoded }) => Result.match(decoded, { onFailure: (issue) => [`harness event ${record.id}#${row.seq}: ${String(issue)}`], onSuccess: () => [] })) };
|
|
30
|
+
}), messages = hasTable(source, "messages"), conversations = hasTable(source, "conversations"), conversationRows = conversations ? source.query("SELECT id, workspace_dir, title, created_at FROM conversations ORDER BY rowid").all() : [], orphanRows = messages ? source.query(`SELECT conversation_id AS id, MIN(created_at) AS created_at FROM messages ${conversations ? "WHERE conversation_id NOT IN (SELECT id FROM conversations)" : ""} GROUP BY conversation_id`).all().map((row) => {
|
|
31
|
+
const target = stored(row.id), planned = Option.fromNullishOr(harness.find((entry) => entry.plan.id === row.id)?.plan);
|
|
32
|
+
return {
|
|
33
|
+
id: row.id,
|
|
34
|
+
title: null,
|
|
35
|
+
workspace_dir: Option.getOrElse(Option.orElse(Option.map(target, (head) => head.owner), () => Option.map(planned, (plan) => plan.owner)), () => Option.getOrElse(owner, () => "conversation-store")),
|
|
36
|
+
created_at: Option.getOrElse(Option.orElse(Option.map(target, (head) => head.created_at), () => Option.map(planned, (plan) => plan.createdAt)), () => row.created_at)
|
|
37
|
+
};
|
|
38
|
+
}) : [], checkpoints = hasTable(source, "checkpoints"), outcomes = hasTable(source, "run_outcomes"), conversationPlans = [...conversationRows, ...orphanRows].map((row) => ({
|
|
39
|
+
id: row.id,
|
|
40
|
+
owner: row.workspace_dir ?? "conversation-store",
|
|
41
|
+
origin: "conversation",
|
|
42
|
+
createdAt: row.created_at,
|
|
43
|
+
kinds: CONVERSATION_KINDS,
|
|
44
|
+
meta: { workspace: row.workspace_dir ?? "conversation-store", projection: "conversation" },
|
|
45
|
+
events: [
|
|
46
|
+
...messages ? source.query("SELECT position, content, created_at FROM messages WHERE conversation_id = ? ORDER BY position").all(row.id).map((message) => ({ kind: "conversation.message", at: message.created_at, data: { position: message.position, content: message.content } })) : [],
|
|
47
|
+
...checkpoints ? source.query("SELECT message_position, summary, created_at FROM checkpoints WHERE conversation_id = ? ORDER BY rowid").all(row.id).map((checkpoint) => ({ kind: "conversation.checkpoint", at: checkpoint.created_at, data: { messagePosition: checkpoint.message_position, summary: checkpoint.summary, createdAt: checkpoint.created_at } })) : [],
|
|
48
|
+
...row.title === null ? [] : [{ kind: "conversation.title", at: row.created_at, data: { title: row.title } }],
|
|
49
|
+
...outcomes ? source.query("SELECT at, outcome, reason FROM run_outcomes WHERE conversation_id = ? ORDER BY at, rowid").all(row.id).map((outcome) => ({ kind: "conversation.outcome", at: outcome.at, data: { outcome: outcome.outcome, reason: outcome.reason, at: outcome.at } })) : []
|
|
50
|
+
]
|
|
51
|
+
})), skipped = [
|
|
52
|
+
...records.flatMap(({ row, decoded }) => Result.match(decoded, { onFailure: (issue) => [`harness session ${row.id}: ${String(issue)}`], onSuccess: () => [] })),
|
|
53
|
+
...harness.flatMap((entry) => entry.skipped)
|
|
54
|
+
], plans = [...harness.map((entry) => entry.plan), ...conversationPlans].filter((plan) => !removed(plan.id)), identityConflict = plans.find((plan) => plans.some((other) => other.id === plan.id && other.owner !== plan.owner) || Option.exists(stored(plan.id), (head) => head.owner !== plan.owner || head.origin === plan.origin && head.created_at !== plan.createdAt));
|
|
55
|
+
if (identityConflict !== void 0)
|
|
56
|
+
return { refused: Option.some(`session ${identityConflict.id} has a conflicting legacy identity`), skipped };
|
|
57
|
+
const indexed = plans.map((plan) => {
|
|
58
|
+
const previous = database.query(`SELECT kind, data FROM session_log_events WHERE session_id = ? AND kind IN (${plan.kinds.map(() => "?").join(", ")}) ORDER BY seq`).all(plan.id, ...plan.kinds).map((row) => ({ kind: row.kind, data: JSON.parse(row.data) })).map((event) => ({ place: placeOf(event), content: `${event.kind}
|
|
59
|
+
${canonicalJson(event.data)}` })), places = new Map(previous.flatMap((event) => Option.toArray(Option.map(event.place, (place) => [place, event.content])))), present = new Set(previous.map((event) => event.content)), incoming = plan.events.map((event) => ({ event, place: placeOf(event), content: `${event.kind}
|
|
60
|
+
${canonicalJson(event.data)}` }));
|
|
61
|
+
return {
|
|
62
|
+
plan,
|
|
63
|
+
conflict: incoming.some((entry) => Option.exists(entry.place, (place) => Option.exists(Option.fromNullishOr(places.get(place)), (content) => content !== entry.content))),
|
|
64
|
+
fresh: incoming.filter((entry) => !present.has(entry.content)).map((entry) => entry.event)
|
|
65
|
+
};
|
|
66
|
+
}), positionConflict = indexed.find((entry) => entry.conflict);
|
|
67
|
+
if (positionConflict !== void 0)
|
|
68
|
+
return { refused: Option.some(`session ${positionConflict.plan.id} has a conflicting legacy position`), skipped };
|
|
69
|
+
const header = database.query("INSERT OR IGNORE INTO session_heads (id, owner, origin, created_at, meta, updated_at) VALUES (?, ?, ?, ?, ?, ?)"), insert = database.query("INSERT INTO session_log_events (session_id, seq, turn, kind, at, data) VALUES (?, ?, NULL, ?, ?, ?)");
|
|
70
|
+
indexed.forEach(({ plan, fresh }) => {
|
|
71
|
+
header.run(plan.id, plan.owner, plan.origin, plan.createdAt, JSON.stringify(plan.meta), plan.createdAt);
|
|
72
|
+
if (fresh.length === 0)
|
|
73
|
+
return;
|
|
74
|
+
const base = database.query("SELECT seq FROM session_heads WHERE id = ?").get(plan.id)?.seq ?? 0;
|
|
75
|
+
fresh.forEach((event, index) => insert.run(plan.id, base + index + 1, event.kind, event.at, JSON.stringify(event.data)));
|
|
76
|
+
database.query("UPDATE session_heads SET seq = ?, revision = revision + 1, updated_at = MAX(updated_at, ?) WHERE id = ?").run(base + fresh.length, fresh.reduce((latest, event) => Math.max(latest, event.at), 0), plan.id);
|
|
77
|
+
});
|
|
78
|
+
database.query("INSERT INTO session_log_imports VALUES (?, ?)").run(sourceId, Date.now());
|
|
79
|
+
return { refused: Option.none(), skipped };
|
|
80
|
+
}).immediate();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Effect, Layer, Option } from "effect";
|
|
2
|
+
import type { Scope } from "effect";
|
|
3
|
+
import { ConversationId, SessionLog, SessionLogError } from "@xandreed/core";
|
|
4
|
+
import type { JsonObject } from "@xandreed/core";
|
|
5
|
+
/** One conversation as the positional listing shows it: its first message and its latest title and outcome, as stored. */
|
|
6
|
+
export interface ConversationOverview {
|
|
7
|
+
readonly id: ConversationId;
|
|
8
|
+
readonly createdAt: number;
|
|
9
|
+
readonly first: Option.Option<JsonObject>;
|
|
10
|
+
readonly title: Option.Option<JsonObject>;
|
|
11
|
+
readonly outcome: Option.Option<JsonObject>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* What this package's compatibility projections use of a SQLite log beyond
|
|
15
|
+
* the SessionLog contract: set-based reads, retention in one transaction,
|
|
16
|
+
* and importing a host's own older file.
|
|
17
|
+
*/
|
|
18
|
+
export interface SqliteLogInternals {
|
|
19
|
+
/** The owner's conversation sessions (origin `conversation`), newest first, in one query. */
|
|
20
|
+
readonly conversations: (owner: string) => Effect.Effect<ReadonlyArray<ConversationOverview>, SessionLogError>;
|
|
21
|
+
/** Remove the conversation sessions created before `before` in one transaction, then reclaim the space; returns how many. */
|
|
22
|
+
readonly prune: (before: number) => Effect.Effect<number, SessionLogError>;
|
|
23
|
+
/** Import older files through read-only connections (absent ones are skipped); orphan rows without a session default to `owner`. */
|
|
24
|
+
readonly importLegacy: (paths: ReadonlyArray<string>, owner: Option.Option<string>) => Effect.Effect<void, SessionLogError>;
|
|
25
|
+
}
|
|
26
|
+
/** The SQLite internals of a log `SessionLogSqliteLive` built; none for any other SessionLog. */
|
|
27
|
+
export declare const sqliteLogInternals: (log: SessionLog["Service"]) => Option.Option<SqliteLogInternals>;
|
|
28
|
+
/**
|
|
29
|
+
* The authoritative session journal in one SQLite file: `session_heads`
|
|
30
|
+
* and `session_log_events`. Legacy tables remain preserved, read only after
|
|
31
|
+
* their import. Removal cascades to events and children and leaves a
|
|
32
|
+
* tombstone, so no import brings a removed session back. A commit is one
|
|
33
|
+
* IMMEDIATE transaction that checks the revision and the clock before it
|
|
34
|
+
* writes; the clock is the process's.
|
|
35
|
+
*/
|
|
36
|
+
export declare const makeSessionLogSqlite: (path: string, options?: {
|
|
37
|
+
readonly legacyPaths?: ReadonlyArray<string>;
|
|
38
|
+
readonly legacyOwner?: string;
|
|
39
|
+
}) => Effect.Effect<SessionLog["Service"], SessionLogError, Scope.Scope>;
|
|
40
|
+
/** The session log over one SQLite file (see `makeSessionLogSqlite`). */
|
|
41
|
+
export declare const SessionLogSqliteLive: (path: string, options?: {
|
|
42
|
+
readonly legacyPaths?: ReadonlyArray<string>;
|
|
43
|
+
readonly legacyOwner?: string;
|
|
44
|
+
}) => Layer.Layer<SessionLog, SessionLogError>;
|
|
45
|
+
/** The session log in a SQLite file, as a plugin: provides SessionLog. */
|
|
46
|
+
export declare const sessionLogSqlitePlugin: import("@xandreed/core").TypedPlugin<{
|
|
47
|
+
readonly path: string;
|
|
48
|
+
}, {
|
|
49
|
+
readonly path: string;
|
|
50
|
+
}, SessionLog, SessionLogError, never>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { chmodSync, existsSync, realpathSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { Clock, Effect, Layer, Option, Schema } from "effect";
|
|
6
|
+
import {
|
|
7
|
+
ConversationId,
|
|
8
|
+
definePlugin,
|
|
9
|
+
LeaseExpired,
|
|
10
|
+
RevisionConflict,
|
|
11
|
+
SessionExists,
|
|
12
|
+
SessionLog,
|
|
13
|
+
SessionLogError,
|
|
14
|
+
SessionMissing
|
|
15
|
+
} from "@xandreed/core";
|
|
16
|
+
import { importLegacySessions, LEGACY_BOOKKEEPING } from "./legacy-session.adapter.js";
|
|
17
|
+
const failure = (code) => (error) => new SessionLogError({ code, message: String(error) }), json = (text) => JSON.parse(text), headOf = (row, now) => ({
|
|
18
|
+
header: {
|
|
19
|
+
id: ConversationId.make(row.id),
|
|
20
|
+
owner: row.owner,
|
|
21
|
+
origin: row.origin,
|
|
22
|
+
createdAt: row.created_at,
|
|
23
|
+
meta: json(row.meta),
|
|
24
|
+
parent: row.parent_id === null ? Option.none() : Option.some({
|
|
25
|
+
id: ConversationId.make(row.parent_id),
|
|
26
|
+
through: row.parent_through ?? 0,
|
|
27
|
+
turnAtFork: row.turn_at_fork ?? 0
|
|
28
|
+
})
|
|
29
|
+
},
|
|
30
|
+
seq: row.seq,
|
|
31
|
+
revision: row.revision,
|
|
32
|
+
state: json(row.state),
|
|
33
|
+
updatedAt: row.updated_at,
|
|
34
|
+
now
|
|
35
|
+
}), eventOf = (session) => (row) => ({
|
|
36
|
+
session,
|
|
37
|
+
seq: row.seq,
|
|
38
|
+
turn: Option.fromNullishOr(row.turn),
|
|
39
|
+
kind: row.kind,
|
|
40
|
+
at: row.at,
|
|
41
|
+
data: json(row.data)
|
|
42
|
+
}), internals = new WeakMap;
|
|
43
|
+
export const sqliteLogInternals = (log) => Option.fromNullishOr(internals.get(log)), makeSessionLogSqlite = (path, options = {}) => Effect.gen(function* () {
|
|
44
|
+
yield* Effect.tryPromise({ try: () => mkdir(dirname(path), { recursive: !0 }), catch: failure("store.open") });
|
|
45
|
+
const db = yield* Effect.acquireRelease(Effect.try({ try: () => new Database(path, { create: !0, strict: !0 }), catch: failure("store.open") }), (database) => Effect.sync(() => database.close())), operation = (work) => Effect.try({ try: work, catch: failure("store.io") });
|
|
46
|
+
yield* operation(() => chmodSync(path, 384));
|
|
47
|
+
yield* operation(() => db.exec(`
|
|
48
|
+
PRAGMA journal_mode = WAL;
|
|
49
|
+
PRAGMA busy_timeout = 5000;
|
|
50
|
+
PRAGMA foreign_keys = ON;
|
|
51
|
+
CREATE TABLE IF NOT EXISTS session_heads (
|
|
52
|
+
id TEXT PRIMARY KEY, owner TEXT NOT NULL, origin TEXT NOT NULL, created_at INTEGER NOT NULL, meta TEXT NOT NULL,
|
|
53
|
+
parent_id TEXT REFERENCES session_heads(id) ON DELETE CASCADE, parent_through INTEGER, turn_at_fork INTEGER,
|
|
54
|
+
seq INTEGER NOT NULL DEFAULT 0, revision INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT '{}', updated_at INTEGER NOT NULL);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS session_heads_owner ON session_heads(owner, updated_at DESC, id DESC);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS session_heads_parent ON session_heads(parent_id);
|
|
57
|
+
CREATE TABLE IF NOT EXISTS session_log_events (
|
|
58
|
+
session_id TEXT NOT NULL REFERENCES session_heads(id) ON DELETE CASCADE, seq INTEGER NOT NULL, turn INTEGER,
|
|
59
|
+
kind TEXT NOT NULL, at INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(session_id, seq));
|
|
60
|
+
CREATE INDEX IF NOT EXISTS session_log_events_kind ON session_log_events(session_id, kind, seq);
|
|
61
|
+
${LEGACY_BOOKKEEPING}
|
|
62
|
+
`));
|
|
63
|
+
const imported = (source, sourceId, owner) => operation(() => importLegacySessions(db, source, sourceId, owner)).pipe(Effect.tap((result) => Effect.forEach(result.skipped, (row) => Effect.logWarning(`legacy import of ${sourceId}: skipped an undecodable row: ${row}`), { discard: !0 })), Effect.flatMap((result) => Option.match(result.refused, {
|
|
64
|
+
onNone: () => Effect.void,
|
|
65
|
+
onSome: (message) => Effect.fail(new SessionLogError({ code: "store.legacy-conflict", message }))
|
|
66
|
+
}))), importLegacy = (paths, owner) => Effect.gen(function* () {
|
|
67
|
+
const destination = yield* operation(() => realpathSync(path)), sources = yield* operation(() => [...new Set(paths.filter(existsSync).map((source) => realpathSync(source)))].filter((source) => source !== destination));
|
|
68
|
+
yield* Effect.forEach(sources, (source) => Effect.acquireUseRelease(operation(() => {
|
|
69
|
+
const legacy = new Database(source, { readonly: !0, strict: !0 });
|
|
70
|
+
legacy.exec("PRAGMA busy_timeout = 5000");
|
|
71
|
+
return legacy;
|
|
72
|
+
}), (legacy) => imported(legacy, source, owner), (legacy) => Effect.sync(() => legacy.close())), { discard: !0 });
|
|
73
|
+
}), owner = Option.fromNullishOr(options.legacyOwner);
|
|
74
|
+
yield* imported(db, "local", owner);
|
|
75
|
+
yield* importLegacy(options.legacyPaths ?? [], owner);
|
|
76
|
+
const headRow = (id) => db.query("SELECT * FROM session_heads WHERE id = ?").get(id), removeTrees = (ids, now) => ids.forEach((id) => {
|
|
77
|
+
db.query(`WITH RECURSIVE tree(id) AS (SELECT id FROM session_heads WHERE id = ? UNION SELECT child.id FROM session_heads child JOIN tree ON child.parent_id = tree.id)
|
|
78
|
+
INSERT OR IGNORE INTO session_log_tombstones (session_id, removed_at) SELECT id, ? FROM tree`).run(id, now);
|
|
79
|
+
db.query("DELETE FROM session_heads WHERE id = ?").run(id);
|
|
80
|
+
}), log = SessionLog.of({
|
|
81
|
+
create: (header) => Effect.gen(function* () {
|
|
82
|
+
const now = yield* Clock.currentTimeMillis, outcome = yield* operation(() => db.transaction(() => {
|
|
83
|
+
if (headRow(header.id) !== null)
|
|
84
|
+
return "exists";
|
|
85
|
+
if (Option.isSome(header.parent) && headRow(header.parent.value.id) === null)
|
|
86
|
+
return "orphan";
|
|
87
|
+
const parent = Option.getOrNull(header.parent);
|
|
88
|
+
db.query("INSERT INTO session_heads (id, owner, origin, created_at, meta, parent_id, parent_through, turn_at_fork, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(header.id, header.owner, header.origin, header.createdAt, JSON.stringify(header.meta), parent?.id ?? null, parent?.through ?? null, parent?.turnAtFork ?? null, now);
|
|
89
|
+
return "created";
|
|
90
|
+
}).immediate());
|
|
91
|
+
if (outcome === "exists")
|
|
92
|
+
return yield* Effect.fail(new SessionExists({ session: header.id }));
|
|
93
|
+
if (outcome === "orphan")
|
|
94
|
+
return yield* Effect.fail(new SessionMissing({ session: Option.match(header.parent, { onNone: () => header.id, onSome: (parent) => parent.id }) }));
|
|
95
|
+
const row = yield* operation(() => headRow(header.id));
|
|
96
|
+
return row === null ? yield* Effect.fail(new SessionLogError({ code: "store.io", message: "the new session was not stored" })) : headOf(row, now);
|
|
97
|
+
}),
|
|
98
|
+
head: (id) => Effect.gen(function* () {
|
|
99
|
+
const now = yield* Clock.currentTimeMillis, row = yield* operation(() => headRow(id));
|
|
100
|
+
return row === null ? yield* Effect.fail(new SessionMissing({ session: id })) : headOf(row, now);
|
|
101
|
+
}),
|
|
102
|
+
list: (query) => Clock.currentTimeMillis.pipe(Effect.flatMap((now) => operation(() => {
|
|
103
|
+
const before = Option.getOrNull(query.before), parent = Option.getOrNull(query.parent);
|
|
104
|
+
return db.query(`
|
|
105
|
+
SELECT * FROM session_heads
|
|
106
|
+
WHERE owner = ? AND ((? IS NULL AND parent_id IS NULL) OR parent_id = ?)
|
|
107
|
+
AND (? IS NULL OR updated_at < ? OR (updated_at = ? AND id < ?))
|
|
108
|
+
ORDER BY updated_at DESC, id DESC LIMIT ?`).all(query.owner, parent, parent, before?.updatedAt ?? null, before?.updatedAt ?? null, before?.updatedAt ?? null, before?.id ?? null, Math.max(0, query.limit)).map((row) => headOf(row, now));
|
|
109
|
+
}))),
|
|
110
|
+
read: (id, query) => Effect.gen(function* () {
|
|
111
|
+
if ((yield* operation(() => headRow(id))) === null)
|
|
112
|
+
return yield* Effect.fail(new SessionMissing({ session: id }));
|
|
113
|
+
const kinds = query.kinds.length === 0 ? "" : ` AND kind IN (${query.kinds.map(() => "?").join(", ")})`, limit = Option.match(query.limit, { onNone: () => -1, onSome: (value) => Math.max(0, value) });
|
|
114
|
+
return yield* operation(() => db.query(`SELECT seq, turn, kind, at, data FROM session_log_events WHERE session_id = ? AND seq > ?${kinds} ORDER BY seq LIMIT ?`).all(id, query.after, ...query.kinds, limit).map(eventOf(id)));
|
|
115
|
+
}),
|
|
116
|
+
commit: (id, commit) => Clock.currentTimeMillis.pipe(Effect.flatMap((at) => operation(() => db.transaction(() => {
|
|
117
|
+
const head = db.query("SELECT seq, revision FROM session_heads WHERE id = ?").get(id);
|
|
118
|
+
if (head === null)
|
|
119
|
+
return { _tag: "Missing" };
|
|
120
|
+
if (head.revision !== commit.expect)
|
|
121
|
+
return { _tag: "Conflict", actual: head.revision };
|
|
122
|
+
if (Option.isSome(commit.notAfter) && at > commit.notAfter.value)
|
|
123
|
+
return { _tag: "Expired" };
|
|
124
|
+
const events = commit.events.map((draft, index) => ({
|
|
125
|
+
session: id,
|
|
126
|
+
seq: head.seq + index + 1,
|
|
127
|
+
turn: draft.turn,
|
|
128
|
+
kind: draft.kind,
|
|
129
|
+
at,
|
|
130
|
+
data: json(JSON.stringify(draft.data))
|
|
131
|
+
})), insert = db.query("INSERT INTO session_log_events (session_id, seq, turn, kind, at, data) VALUES (?, ?, ?, ?, ?, ?)");
|
|
132
|
+
events.forEach((event) => insert.run(id, event.seq, Option.getOrNull(event.turn), event.kind, at, JSON.stringify(event.data)));
|
|
133
|
+
db.query("UPDATE session_heads SET revision = revision + 1, seq = ?, state = COALESCE(?, state), updated_at = ? WHERE id = ?").run(head.seq + events.length, Option.match(commit.state, { onNone: () => null, onSome: (state) => JSON.stringify(state) }), at, id);
|
|
134
|
+
return { _tag: "Done", committed: { revision: head.revision + 1, seq: head.seq + events.length, events, at } };
|
|
135
|
+
}).immediate()).pipe(Effect.flatMap((outcome) => {
|
|
136
|
+
if (outcome._tag === "Done")
|
|
137
|
+
return Effect.succeed(outcome.committed);
|
|
138
|
+
if (outcome._tag === "Missing")
|
|
139
|
+
return Effect.fail(new SessionMissing({ session: id }));
|
|
140
|
+
if (outcome._tag === "Conflict")
|
|
141
|
+
return Effect.fail(new RevisionConflict({ session: id, expected: commit.expect, actual: outcome.actual }));
|
|
142
|
+
return Effect.fail(new LeaseExpired({ session: id, notAfter: Option.getOrElse(commit.notAfter, () => at), now: at }));
|
|
143
|
+
})))),
|
|
144
|
+
remove: (id) => Clock.currentTimeMillis.pipe(Effect.flatMap((now) => operation(() => db.transaction(() => removeTrees([id], now)).immediate())))
|
|
145
|
+
});
|
|
146
|
+
internals.set(log, {
|
|
147
|
+
conversations: (owner) => operation(() => db.query(`
|
|
148
|
+
SELECT head.id AS id, head.created_at AS created_at,
|
|
149
|
+
(SELECT event.data FROM session_log_events event WHERE event.session_id = head.id AND event.kind = 'conversation.message' ORDER BY event.seq LIMIT 1) AS first,
|
|
150
|
+
(SELECT event.data FROM session_log_events event WHERE event.session_id = head.id AND event.kind = 'conversation.title' ORDER BY event.seq DESC LIMIT 1) AS title,
|
|
151
|
+
(SELECT event.data FROM session_log_events event WHERE event.session_id = head.id AND event.kind = 'conversation.outcome' ORDER BY event.seq DESC LIMIT 1) AS outcome
|
|
152
|
+
FROM session_heads head WHERE head.owner = ? AND head.origin = 'conversation' ORDER BY head.created_at DESC, head.id DESC`).all(owner).map((row) => ({
|
|
153
|
+
id: ConversationId.make(row.id),
|
|
154
|
+
createdAt: row.created_at,
|
|
155
|
+
first: Option.map(Option.fromNullishOr(row.first), json),
|
|
156
|
+
title: Option.map(Option.fromNullishOr(row.title), json),
|
|
157
|
+
outcome: Option.map(Option.fromNullishOr(row.outcome), json)
|
|
158
|
+
}))),
|
|
159
|
+
prune: (before) => Clock.currentTimeMillis.pipe(Effect.flatMap((now) => operation(() => {
|
|
160
|
+
const removed = db.transaction(() => {
|
|
161
|
+
const ids = db.query("SELECT id FROM session_heads WHERE origin = 'conversation' AND created_at < ?").all(before).map((row) => row.id);
|
|
162
|
+
removeTrees(ids, now);
|
|
163
|
+
return ids.length;
|
|
164
|
+
}).immediate();
|
|
165
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE);");
|
|
166
|
+
return removed;
|
|
167
|
+
}))),
|
|
168
|
+
importLegacy
|
|
169
|
+
});
|
|
170
|
+
return log;
|
|
171
|
+
}), SessionLogSqliteLive = (path, options = {}) => Layer.effect(SessionLog, makeSessionLogSqlite(path, options));
|
|
172
|
+
const LogConfig = Schema.Struct({ path: Schema.String });
|
|
173
|
+
export const sessionLogSqlitePlugin = definePlugin({
|
|
174
|
+
id: "@xandreed/plugin-session-sqlite/log",
|
|
175
|
+
version: "0.8.0-next.0",
|
|
176
|
+
scope: "runtime",
|
|
177
|
+
config: LogConfig,
|
|
178
|
+
defaults: { path: ".efferent/runtime/sessions.db" },
|
|
179
|
+
provides: [SessionLog],
|
|
180
|
+
layer: ({ path }) => SessionLogSqliteLive(path)
|
|
181
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,3 +1,20 @@
|
|
|
1
|
-
import { Layer } from "effect";
|
|
2
|
-
import { ConversationStore, StoreError } from "@xandreed/core";
|
|
3
|
-
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { ConversationStore, SessionLog, StoreError } from "@xandreed/core";
|
|
3
|
+
/**
|
|
4
|
+
* @deprecated Positional conversation API projected over the host's unified SessionLog.
|
|
5
|
+
*
|
|
6
|
+
* Over this package's SQLite log it lists a workspace in one query and prunes
|
|
7
|
+
* in one transaction, and `legacy` imports the host's own older message file
|
|
8
|
+
* into it first (orphan rows without a session default to `owner`). Over any
|
|
9
|
+
* other log the listing reads each conversation's first message and latest
|
|
10
|
+
* title and outcome, pruning needs the host's `prune`, and `legacy` fails.
|
|
11
|
+
*/
|
|
12
|
+
export declare const ConversationStoreProjectionLive: (options?: {
|
|
13
|
+
readonly prune?: (beforeEpochMs: number) => Effect.Effect<number, StoreError>;
|
|
14
|
+
readonly legacy?: {
|
|
15
|
+
readonly paths: ReadonlyArray<string>;
|
|
16
|
+
readonly owner: string;
|
|
17
|
+
};
|
|
18
|
+
}) => Layer.Layer<ConversationStore, StoreError, SessionLog>;
|
|
19
|
+
/** @deprecated Convenience host composition: the projection over one SQLite session log, opened once. */
|
|
20
|
+
export declare const SqliteConversationStoreLive: (dbPath: string) => Layer.Layer<ConversationStore, StoreError>;
|
|
@@ -1,175 +1,100 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
);
|
|
14
|
-
CREATE TABLE IF NOT EXISTS messages (
|
|
15
|
-
conversation_id TEXT NOT NULL,
|
|
16
|
-
position INTEGER NOT NULL,
|
|
17
|
-
content TEXT NOT NULL,
|
|
18
|
-
created_at INTEGER NOT NULL,
|
|
19
|
-
PRIMARY KEY (conversation_id, position)
|
|
20
|
-
);
|
|
21
|
-
CREATE TABLE IF NOT EXISTS checkpoints (
|
|
22
|
-
conversation_id TEXT NOT NULL,
|
|
23
|
-
message_position INTEGER NOT NULL,
|
|
24
|
-
summary TEXT NOT NULL,
|
|
25
|
-
created_at INTEGER NOT NULL
|
|
26
|
-
);
|
|
27
|
-
`,
|
|
28
|
-
`
|
|
29
|
-
CREATE INDEX IF NOT EXISTS checkpoints_by_conversation
|
|
30
|
-
ON checkpoints (conversation_id, message_position);
|
|
31
|
-
CREATE INDEX IF NOT EXISTS conversations_by_workspace
|
|
32
|
-
ON conversations (workspace_dir);
|
|
33
|
-
`,
|
|
34
|
-
`
|
|
35
|
-
CREATE TABLE IF NOT EXISTS run_outcomes (
|
|
36
|
-
conversation_id TEXT NOT NULL,
|
|
37
|
-
at INTEGER NOT NULL,
|
|
38
|
-
outcome TEXT NOT NULL,
|
|
39
|
-
reason TEXT NOT NULL
|
|
40
|
-
);
|
|
41
|
-
CREATE INDEX IF NOT EXISTS run_outcomes_by_conversation
|
|
42
|
-
ON run_outcomes (conversation_id, at);
|
|
43
|
-
`
|
|
44
|
-
], migrate = (db) => {
|
|
45
|
-
const version = db.query("PRAGMA user_version").get().user_version;
|
|
46
|
-
MIGRATIONS.slice(version).forEach((step) => db.transaction(() => db.exec(step))());
|
|
47
|
-
db.exec(`PRAGMA user_version = ${MIGRATIONS.length}`);
|
|
48
|
-
}, decodeMessage = Schema.decodeUnknownEither(Schema.parseJson(AgentMessage)), tryDb = (run) => Effect.try({
|
|
49
|
-
try: run,
|
|
50
|
-
catch: (e) => new StoreError({ message: String(e) })
|
|
51
|
-
}), salvageRows = (rows, where) => Effect.forEach(rows, (row) => Either.match(decodeMessage(row.content), {
|
|
52
|
-
onLeft: (issue) => Effect.logWarning(`${where}: skipping undecodable message row: ${String(issue)}`).pipe(Effect.as(Option.none())),
|
|
53
|
-
onRight: (message) => Effect.succeed(Option.some(message))
|
|
54
|
-
})).pipe(Effect.map((decoded) => decoded.filter(Option.isSome).map((some) => some.value))), salvagePositioned = (rows, where) => Effect.forEach(rows, (row) => Either.match(decodeMessage(row.content), {
|
|
55
|
-
onLeft: (issue) => Effect.logWarning(`${where}: skipping undecodable message row at ${row.position}: ${String(issue)}`).pipe(Effect.as(Option.none())),
|
|
56
|
-
onRight: (message) => Effect.succeed(Option.some(new StoredMessage({ position: row.position, message })))
|
|
57
|
-
})).pipe(Effect.map((decoded) => decoded.filter(Option.isSome).map((some) => some.value)));
|
|
58
|
-
export const SqliteConversationStoreLive = (dbPath) => Layer.scoped(ConversationStore, Effect.gen(function* () {
|
|
59
|
-
const db = yield* tryDb(() => {
|
|
60
|
-
mkdirSync(dirname(dbPath), { recursive: !0 });
|
|
61
|
-
const database = new Database(dbPath, { create: !0 });
|
|
62
|
-
chmodSync(dbPath, 384);
|
|
63
|
-
database.exec("PRAGMA busy_timeout = 5000;");
|
|
64
|
-
database.exec("PRAGMA journal_mode = WAL;");
|
|
65
|
-
migrate(database);
|
|
66
|
-
return database;
|
|
1
|
+
import { Clock, Effect, Layer, Option, Result, Schema } from "effect";
|
|
2
|
+
import { AgentMessage, Checkpoint, ConversationId, ConversationStore, ConversationSummary, HarnessError, RunOutcomeRecord, SessionLog, StoredMessage, StoreError } from "@xandreed/core";
|
|
3
|
+
import { compatibilityCommit, compatibilityHistory, compatibilityLast } from "../compatibility.adapter.js";
|
|
4
|
+
import { makeSessionLogSqlite, sqliteLogInternals } from "../session-log.adapter.js";
|
|
5
|
+
const MESSAGE = "conversation.message", CHECKPOINT = "conversation.checkpoint", TITLE = "conversation.title", OUTCOME = "conversation.outcome", failed = (error) => new StoreError({ message: error instanceof Error ? error.message : String(error) }), decodeMessage = Schema.decodeUnknownResult(Schema.fromJsonString(AgentMessage)), decodeListedOutcome = Schema.decodeUnknownResult(Schema.Struct({
|
|
6
|
+
outcome: Schema.Literals(["ok", "partial"]),
|
|
7
|
+
reason: Schema.Literals(["completed", "step-cap", "degenerate-loop"])
|
|
8
|
+
})), draft = (kind, data) => ({ kind, data, turn: Option.none() }), positioned = (events) => Effect.forEach(events.filter((event) => event.kind === MESSAGE), (event) => {
|
|
9
|
+
const decoded = decodeMessage(typeof event.data.content === "string" ? event.data.content : "");
|
|
10
|
+
return Result.match(decoded, {
|
|
11
|
+
onFailure: (issue) => Effect.logWarning(`conversation ${event.session}: skipping undecodable message at ${event.data.position}: ${String(issue)}`).pipe(Effect.as(Option.none())),
|
|
12
|
+
onSuccess: (message) => typeof event.data.position === "number" ? Effect.succeed(Option.some(new StoredMessage({ position: event.data.position, message }))) : Effect.succeed(Option.none())
|
|
67
13
|
});
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}),
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}))),
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}))),
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
onNone: () =>
|
|
135
|
-
onSome: (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
WHERE m.conversation_id = c.id ORDER BY m.position ASC LIMIT 1)
|
|
153
|
-
AS first_content,
|
|
154
|
-
(SELECT o.outcome FROM run_outcomes o
|
|
155
|
-
WHERE o.conversation_id = c.id ORDER BY o.at DESC, o.rowid DESC LIMIT 1)
|
|
156
|
-
AS last_outcome,
|
|
157
|
-
(SELECT o.reason FROM run_outcomes o
|
|
158
|
-
WHERE o.conversation_id = c.id ORDER BY o.at DESC, o.rowid DESC LIMIT 1)
|
|
159
|
-
AS last_reason
|
|
160
|
-
FROM conversations c WHERE c.workspace_dir = ?
|
|
161
|
-
ORDER BY c.created_at DESC`).all(workspaceDir).map((row) => {
|
|
162
|
-
const first = Option.fromNullable(row.first_content).pipe(Option.flatMap((content) => Either.getRight(decodeMessage(content))), Option.flatMap((parsed) => parsed.role === "user" ? Option.some(parsed.content.slice(0, 120)) : Option.none())), lastOutcome = Option.flatMap(Option.all([Option.fromNullable(row.last_outcome), Option.fromNullable(row.last_reason)]), ([outcome, reason]) => Either.getRight(Schema.decodeUnknownEither(Schema.Struct({
|
|
163
|
-
outcome: Schema.Literal("ok", "partial"),
|
|
164
|
-
reason: Schema.Literal("completed", "step-cap", "degenerate-loop")
|
|
165
|
-
}))({ outcome, reason })));
|
|
166
|
-
return new ConversationSummary({
|
|
167
|
-
id: ConversationId.make(row.id),
|
|
168
|
-
createdAt: row.created_at,
|
|
169
|
-
firstPrompt: first,
|
|
170
|
-
title: Option.fromNullable(row.title),
|
|
171
|
-
lastOutcome
|
|
172
|
-
});
|
|
14
|
+
}).pipe(Effect.map((rows) => rows.flatMap(Option.toArray))), latestFold = (events, upTo = Number.MAX_SAFE_INTEGER) => events.filter((event) => event.kind === CHECKPOINT && Number(event.data.messagePosition) <= upTo).reduce((best, event) => Option.match(best, {
|
|
15
|
+
onNone: () => Option.some(event),
|
|
16
|
+
onSome: (current) => Number(event.data.messagePosition) >= Number(current.data.messagePosition) ? Option.some(event) : best
|
|
17
|
+
}), Option.none()), summaryOf = (row) => {
|
|
18
|
+
const first = Option.flatMap(row.first, (data) => typeof data.content === "string" ? Result.getSuccess(decodeMessage(data.content)) : Option.none());
|
|
19
|
+
return new ConversationSummary({
|
|
20
|
+
id: row.id,
|
|
21
|
+
createdAt: row.createdAt,
|
|
22
|
+
title: Option.flatMap(row.title, (data) => typeof data.title === "string" ? Option.some(data.title) : Option.none()),
|
|
23
|
+
firstPrompt: Option.flatMap(first, (message) => message.role === "user" ? Option.some(message.content.slice(0, 120)) : Option.none()),
|
|
24
|
+
lastOutcome: Option.flatMap(row.outcome, (data) => Result.getSuccess(decodeListedOutcome({ outcome: data.outcome, reason: data.reason })))
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
export const ConversationStoreProjectionLive = (options = {}) => Layer.effect(ConversationStore, Effect.gen(function* () {
|
|
28
|
+
const log = yield* SessionLog, sqlite = sqliteLogInternals(log), legacy = Option.fromNullishOr(options.legacy);
|
|
29
|
+
if (Option.isSome(legacy))
|
|
30
|
+
yield* Option.match(sqlite, {
|
|
31
|
+
onNone: () => Effect.fail(new StoreError({ message: "importing an older conversation file requires the SQLite session log" })),
|
|
32
|
+
onSome: (internals) => internals.importLegacy(legacy.value.paths, Option.some(legacy.value.owner)).pipe(Effect.mapError(failed))
|
|
33
|
+
});
|
|
34
|
+
const orEmpty = (read, empty) => read.pipe(Effect.catchTag("HarnessError", (error) => error.code === "session.missing" ? Effect.succeed(empty) : Effect.fail(error)), Effect.mapError(failed)), read = (id) => orEmpty(compatibilityHistory(log, id, [MESSAGE, CHECKPOINT, TITLE, OUTCOME]), []), last = (id, kind, upTo) => orEmpty(compatibilityLast(log, id, [kind], upTo), Option.none()), checkpointOf = (id, events) => Option.match(latestFold(events), {
|
|
35
|
+
onNone: () => Effect.succeed(Option.none()),
|
|
36
|
+
onSome: (latest) => Schema.decodeUnknownEffect(Checkpoint)({ conversationId: id, ...latest.data }).pipe(Effect.map(Option.some), Effect.mapError(failed))
|
|
37
|
+
}), ensure = (id) => log.head(id).pipe(Effect.catchTag("SessionMissing", () => Clock.currentTimeMillis.pipe(Effect.flatMap((createdAt) => log.create({
|
|
38
|
+
id,
|
|
39
|
+
owner: "conversation-store",
|
|
40
|
+
origin: "conversation",
|
|
41
|
+
createdAt,
|
|
42
|
+
meta: { projection: "conversation", workspace: "conversation-store" },
|
|
43
|
+
parent: Option.none()
|
|
44
|
+
}).pipe(Effect.catchTag("SessionExists", () => log.head(id)))))), Effect.mapError(failed), Effect.asVoid), commit = (id, plan) => ensure(id).pipe(Effect.andThen(compatibilityCommit(log, id, (head) => plan(head).pipe(Effect.mapError((error) => new HarnessError({ code: "conversation.store", message: error.message }))))), Effect.map((done) => done.result), Effect.mapError(failed)), lastPosition = (id, head) => last(id, MESSAGE, head.seq).pipe(Effect.map(Option.match({ onNone: () => -1, onSome: (event) => Number(event.data.position) }))), appendAll = (id, messages) => messages.length === 0 ? Effect.succeed([]) : commit(id, (head) => lastPosition(id, head).pipe(Effect.map((previous) => {
|
|
45
|
+
const positions = messages.map((_, index) => previous + index + 1);
|
|
46
|
+
return { drafts: messages.map((message, index) => draft(MESSAGE, { position: positions[index], content: JSON.stringify(message) })), result: positions };
|
|
47
|
+
}))), checkpointAt = (id, summary, messagePosition) => commit(id, () => Clock.currentTimeMillis.pipe(Effect.map((createdAt) => ({
|
|
48
|
+
drafts: [draft(CHECKPOINT, { summary, messagePosition, createdAt })],
|
|
49
|
+
result: void 0
|
|
50
|
+
})))), latestOutcome = (id) => last(id, OUTCOME).pipe(Effect.flatMap(Option.match({
|
|
51
|
+
onNone: () => Effect.succeed(Option.none()),
|
|
52
|
+
onSome: (latest) => Schema.decodeUnknownEffect(RunOutcomeRecord)({ conversationId: id, ...latest.data }).pipe(Effect.map(Option.some), Effect.mapError(failed))
|
|
53
|
+
}))), overviews = (owner) => log.list({ owner, limit: Number.MAX_SAFE_INTEGER, before: Option.none(), parent: Option.none() }).pipe(Effect.mapError(failed), Effect.map((heads) => heads.filter((head) => head.header.origin === "conversation").sort((a, b) => b.header.createdAt - a.header.createdAt || (a.header.id < b.header.id ? 1 : -1))), Effect.flatMap((heads) => Effect.forEach(heads, (head) => Effect.gen(function* () {
|
|
54
|
+
const first = yield* log.read(head.header.id, { after: 0, limit: Option.some(1), kinds: [MESSAGE] }).pipe(Effect.mapError(failed)), title = yield* last(head.header.id, TITLE), outcome = yield* last(head.header.id, OUTCOME);
|
|
55
|
+
return { id: head.header.id, createdAt: head.header.createdAt, first: Option.map(Option.fromNullishOr(first[0]), (event) => event.data), title: Option.map(title, (event) => event.data), outcome: Option.map(outcome, (event) => event.data) };
|
|
56
|
+
}))));
|
|
57
|
+
return ConversationStore.of({
|
|
58
|
+
create: (workspace = "conversation-store") => Clock.currentTimeMillis.pipe(Effect.flatMap((createdAt) => log.create({
|
|
59
|
+
id: ConversationId.make(crypto.randomUUID()),
|
|
60
|
+
owner: workspace,
|
|
61
|
+
origin: "conversation",
|
|
62
|
+
createdAt,
|
|
63
|
+
meta: { workspace, projection: "conversation" },
|
|
64
|
+
parent: Option.none()
|
|
65
|
+
})), Effect.map((head) => head.header.id), Effect.mapError(failed)),
|
|
66
|
+
append: (id, message) => appendAll(id, [message]).pipe(Effect.map((positions) => positions[0] ?? 0)),
|
|
67
|
+
appendAll,
|
|
68
|
+
list: (id) => read(id).pipe(Effect.flatMap(positioned), Effect.map((rows) => rows.map((row) => row.message))),
|
|
69
|
+
listActive: (id) => read(id).pipe(Effect.flatMap((events) => Effect.all([positioned(events), checkpointOf(id, events)])), Effect.map(([rows, checkpoint]) => rows.filter((row) => row.position > Option.match(checkpoint, { onNone: () => -1, onSome: (fold) => fold.messagePosition })))),
|
|
70
|
+
checkpoint: (id, summary) => commit(id, (head) => Effect.gen(function* () {
|
|
71
|
+
const messagePosition = yield* lastPosition(id, head);
|
|
72
|
+
return { drafts: [draft(CHECKPOINT, { summary, messagePosition, createdAt: yield* Clock.currentTimeMillis })], result: void 0 };
|
|
73
|
+
})),
|
|
74
|
+
checkpointAt,
|
|
75
|
+
latestCheckpoint: (id) => read(id).pipe(Effect.flatMap((events) => checkpointOf(id, events))),
|
|
76
|
+
setTitle: (id, title) => commit(id, () => Effect.succeed({ drafts: [draft(TITLE, { title })], result: void 0 })),
|
|
77
|
+
recordOutcome: (id, outcome, reason) => commit(id, () => Clock.currentTimeMillis.pipe(Effect.map((at) => ({ drafts: [draft(OUTCOME, { at, outcome, reason })], result: void 0 })))),
|
|
78
|
+
latestOutcome,
|
|
79
|
+
listByWorkspace: (workspace) => Option.match(sqlite, {
|
|
80
|
+
onNone: () => overviews(workspace),
|
|
81
|
+
onSome: (internals) => internals.conversations(workspace).pipe(Effect.mapError(failed))
|
|
82
|
+
}).pipe(Effect.map((rows) => rows.map(summaryOf))),
|
|
83
|
+
fork: (id, upToPosition = Number.MAX_SAFE_INTEGER) => Effect.gen(function* () {
|
|
84
|
+
const source = yield* log.head(id).pipe(Effect.mapError((error) => new StoreError({ message: `conversation ${id} not found: ${String(error)}` }))), records = yield* read(id), messages = records.filter((event) => event.kind === MESSAGE && Number(event.data.position) <= upToPosition), checkpoint = latestFold(records, upToPosition), title = records.filter((event) => event.kind === TITLE).at(-1)?.data.title, created = yield* log.create({
|
|
85
|
+
id: ConversationId.make(crypto.randomUUID()),
|
|
86
|
+
owner: source.header.owner,
|
|
87
|
+
origin: "conversation",
|
|
88
|
+
createdAt: yield* Clock.currentTimeMillis,
|
|
89
|
+
meta: { ...source.header.meta, forkedFrom: id, projection: "conversation" },
|
|
90
|
+
parent: Option.none()
|
|
91
|
+
}).pipe(Effect.mapError(failed));
|
|
92
|
+
yield* commit(created.header.id, () => Effect.succeed({ drafts: [...messages.map((event) => draft(event.kind, event.data)), ...Option.toArray(Option.map(checkpoint, (fold) => draft(fold.kind, fold.data))), ...typeof title === "string" ? [draft(TITLE, { title: `fork: ${title}` })] : []], result: void 0 })).pipe(Effect.onError(() => log.remove(created.header.id).pipe(Effect.ignore)));
|
|
93
|
+
return created.header.id;
|
|
94
|
+
}).pipe(Effect.uninterruptible),
|
|
95
|
+
prune: options.prune ?? ((before) => Option.match(sqlite, {
|
|
96
|
+
onNone: () => Effect.fail(new StoreError({ message: "pruning requires the storage host's explicit retention adapter" })),
|
|
97
|
+
onSome: (internals) => internals.prune(before).pipe(Effect.mapError(failed))
|
|
173
98
|
}))
|
|
174
|
-
};
|
|
175
|
-
}));
|
|
99
|
+
});
|
|
100
|
+
})), SqliteConversationStoreLive = (dbPath) => Layer.unwrap(makeSessionLogSqlite(dbPath).pipe(Effect.mapError(failed), Effect.map((log) => ConversationStoreProjectionLive().pipe(Layer.provide(Layer.succeed(SessionLog, log))))));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xandreed/plugin-session-sqlite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0-next.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@xandreed/core": "0.
|
|
14
|
-
"effect": "
|
|
13
|
+
"@xandreed/core": "0.8.0-next.0",
|
|
14
|
+
"effect": "4.0.0-rc.118",
|
|
15
15
|
"ws": "^8.21.0"
|
|
16
16
|
},
|
|
17
17
|
"private": false,
|