@open-gitagent/session-store-mongo 0.2.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ComputerAgent contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @computeragent/session-store-mongo
2
+
3
+ MongoDB-backed `SessionStore` for the ComputerAgent harness. Plug-in implementation of the Claude Agent SDK's native `SessionStore` contract — enables cross-process conversation continuation when sessions live in a shared database instead of on local disk.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @computeragent/session-store-mongo
9
+ ```
10
+
11
+ ## Use
12
+
13
+ Register the store with the harness server at boot:
14
+
15
+ ```ts
16
+ import { createHarnessServer } from "@computeragent/harness-server";
17
+ import { MongoSessionStore } from "@computeragent/session-store-mongo";
18
+
19
+ const app = createHarnessServer({
20
+ engines: { /* ... */ },
21
+ identityLoaders: { /* ... */ },
22
+ sessionStores: {
23
+ mongo: (options) => new MongoSessionStore({
24
+ url: process.env.MONGO_URL!,
25
+ database: "computeragent_sessions",
26
+ ...(options as object ?? {}),
27
+ }),
28
+ },
29
+ });
30
+ ```
31
+
32
+ Then have any client request the store by `kind`:
33
+
34
+ ```ts
35
+ const agent = new ComputerAgent({
36
+ source: "...",
37
+ harness: "claude-agent-sdk",
38
+ sessionStore: { kind: "mongo" },
39
+ sessionId: previousSessionId, // for resume
40
+ envs: { ANTHROPIC_API_KEY: "..." },
41
+ });
42
+ ```
43
+
44
+ The server-side builder receives the wire-side `options` field; the SDK never carries connection credentials over the wire — keep them server-side.
45
+
46
+ ## Storage layout
47
+
48
+ One document per session under the `sessions` collection of the configured database (default: `computeragent_sessions`):
49
+
50
+ ```json
51
+ {
52
+ "_id": "<sessionId>",
53
+ "projectKey": "<engine-derived>",
54
+ "entries": [ /* SessionStoreEntry */ ],
55
+ "updatedAt": <Date>
56
+ }
57
+ ```
58
+
59
+ `entries[]` follows the Claude Agent SDK's [`SessionStoreEntry`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) shape — JSONL-equivalent records the SDK appends per turn (user messages, assistant responses, tool calls, system events).
60
+
61
+ ## Architecture notes
62
+
63
+ - **Two engines, one port.** The `SessionStore` interface is engine-agnostic. The Claude Agent engine consumes it natively (the SDK reads/writes through it). The gitagent engine currently manages its own filesystem session format and ignores `ctx.sessionStore`; a future `GitclawSessionStoreAdapter` can mirror gitclaw's session dir into any `SessionStore` for symmetric resume across engines.
64
+ - **Idempotency.** Entries are deduplicated by `uuid`. Re-appends of an entry already present are no-ops — safe for SDK retries and `importSessionToStore` replays.
65
+ - **Single-writer.** A simple `load → filter → write` cycle. Concurrent writes against the same `sessionId` from different harness processes can interleave; the SDK's flow is single-writer per session, which makes this correct in practice. Multi-writer parallelism (e.g., a fan-out farm against one logical session) would require a transactional update pipeline — captured as future work.
66
+
67
+ ## Configuration
68
+
69
+ ```ts
70
+ new MongoSessionStore({
71
+ url: "mongodb://user:pass@host:port/admin", // required
72
+ database: "computeragent_sessions", // default
73
+ collection: "sessions", // default
74
+ clientOptions: { /* MongoClientOptions */ }, // optional, forwarded to driver
75
+ });
76
+ ```
77
+
78
+ `close()` releases the underlying `MongoClient` cleanly. The store auto-connects on first `append`/`load`/`size`/`listSessions` call; explicit `connect()` is offered for callers that want to surface connection errors at boot.
79
+
80
+ ## Tests
81
+
82
+ ```bash
83
+ MONGO_URL="mongodb://..." pnpm --filter @computeragent/session-store-mongo test
84
+ ```
85
+
86
+ Live integration tests use ephemeral `ca_test_<random>` databases that are dropped on teardown — safe to run against shared clusters without polluting other apps' data. With `MONGO_URL` unset the live suite is skipped automatically.
@@ -0,0 +1,22 @@
1
+ import type { SessionStore } from "@open-gitagent/protocol";
2
+ import { type MongoSessionStoreOptions } from "./mongo-store.js";
3
+ /**
4
+ * One-call builder for the harness server's `sessionStores` registry.
5
+ * Wraps `MongoSessionStore` so users can register the Mongo backend in a
6
+ * single line:
7
+ *
8
+ * createHarnessServer({
9
+ * ...,
10
+ * sessionStores: { mongo: mongoSessionStoreBuilder({ url: process.env.MONGO_URL! }) },
11
+ * });
12
+ *
13
+ * The returned builder honors per-call `options` from the wire-side
14
+ * `SessionStoreConfig.options` — useful when different sessions need
15
+ * different databases or collections under the same Mongo cluster:
16
+ *
17
+ * client side: sessionStore: { kind: "mongo", options: { database: "tenant_a" } }
18
+ *
19
+ * Per-call options merge on top of the defaults passed to this helper.
20
+ */
21
+ export declare function mongoSessionStoreBuilder(defaults: MongoSessionStoreOptions): (options?: unknown) => SessionStore;
22
+ //# sourceMappingURL=builder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,EAAqB,KAAK,wBAAwB,EAAE,MAAM,kBAAkB,CAAC;AAEpF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,wBAAwB,GACjC,CAAC,OAAO,CAAC,EAAE,OAAO,KAAK,YAAY,CAMrC"}
@@ -0,0 +1,28 @@
1
+ import { MongoSessionStore } from "./mongo-store.js";
2
+ /**
3
+ * One-call builder for the harness server's `sessionStores` registry.
4
+ * Wraps `MongoSessionStore` so users can register the Mongo backend in a
5
+ * single line:
6
+ *
7
+ * createHarnessServer({
8
+ * ...,
9
+ * sessionStores: { mongo: mongoSessionStoreBuilder({ url: process.env.MONGO_URL! }) },
10
+ * });
11
+ *
12
+ * The returned builder honors per-call `options` from the wire-side
13
+ * `SessionStoreConfig.options` — useful when different sessions need
14
+ * different databases or collections under the same Mongo cluster:
15
+ *
16
+ * client side: sessionStore: { kind: "mongo", options: { database: "tenant_a" } }
17
+ *
18
+ * Per-call options merge on top of the defaults passed to this helper.
19
+ */
20
+ export function mongoSessionStoreBuilder(defaults) {
21
+ if (!defaults.url)
22
+ throw new Error("mongoSessionStoreBuilder: defaults.url is required");
23
+ return (options) => {
24
+ const overrides = (options ?? {});
25
+ return new MongoSessionStore({ ...defaults, ...overrides });
26
+ };
27
+ }
28
+ //# sourceMappingURL=builder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"builder.js","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAiC,MAAM,kBAAkB,CAAC;AAEpF;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,wBAAwB,CACtC,QAAkC;IAElC,IAAI,CAAC,QAAQ,CAAC,GAAG;QAAE,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;IACzF,OAAO,CAAC,OAAO,EAAE,EAAE;QACjB,MAAM,SAAS,GAAG,CAAC,OAAO,IAAI,EAAE,CAAsC,CAAC;QACvE,OAAO,IAAI,iBAAiB,CAAC,EAAE,GAAG,QAAQ,EAAE,GAAG,SAAS,EAAE,CAAC,CAAC;IAC9D,CAAC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,4 @@
1
+ export { MongoSessionStore } from "./mongo-store.js";
2
+ export type { MongoSessionStoreOptions } from "./mongo-store.js";
3
+ export { mongoSessionStoreBuilder } from "./builder.js";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,YAAY,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAC;AACjE,OAAO,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { MongoSessionStore } from "./mongo-store.js";
2
+ export { mongoSessionStoreBuilder } from "./builder.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAErD,OAAO,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC"}
@@ -0,0 +1,56 @@
1
+ import { type MongoClientOptions } from "mongodb";
2
+ import type { SessionKey, SessionStore, SessionStoreEntry } from "@open-gitagent/protocol";
3
+ /**
4
+ * MongoDB-backed SessionStore. One document per session, keyed by sessionId.
5
+ *
6
+ * Document shape:
7
+ * {
8
+ * _id: <sessionId>,
9
+ * projectKey: <string>,
10
+ * entries: [<SessionStoreEntry>, ...],
11
+ * updatedAt: <Date>,
12
+ * }
13
+ *
14
+ * Idempotency by entry.uuid: a duplicate uuid append is skipped. Currently
15
+ * implemented with a load-then-write pattern, which is correct for single-
16
+ * writer flows (the harness server is single-writer per session). Multi-
17
+ * writer correctness requires either an aggregation-pipeline update or a
18
+ * lock; documented limitation.
19
+ *
20
+ * Lifecycle: pass a `mongoUrl` to construct, call `connect()` once before
21
+ * first use (lazy: `append`/`load` will auto-connect on first call), and
22
+ * `close()` when done.
23
+ */
24
+ export interface MongoSessionStoreOptions {
25
+ /** Mongo connection string. Required. */
26
+ readonly url: string;
27
+ /** Database name. Default: `computeragent_sessions`. */
28
+ readonly database?: string;
29
+ /** Collection name. Default: `sessions`. */
30
+ readonly collection?: string;
31
+ /** Optional MongoClient options forwarded verbatim. */
32
+ readonly clientOptions?: MongoClientOptions;
33
+ }
34
+ export declare class MongoSessionStore implements SessionStore {
35
+ private readonly client;
36
+ private readonly databaseName;
37
+ private readonly collectionName;
38
+ private connected;
39
+ private connectPromise;
40
+ constructor(opts: MongoSessionStoreOptions);
41
+ /** Open the connection. Idempotent; lazy callers don't need to invoke this. */
42
+ connect(): Promise<void>;
43
+ /** Close the MongoClient. Safe to call multiple times. */
44
+ close(): Promise<void>;
45
+ append(key: SessionKey, entries: SessionStoreEntry[]): Promise<void>;
46
+ load(key: SessionKey): Promise<SessionStoreEntry[] | null>;
47
+ /** Test/admin helper: number of entries stored for a sessionId. */
48
+ size(sessionId: string): Promise<number>;
49
+ /** Diagnostic: list the most-recently-touched sessions. */
50
+ listSessions(projectKey: string): Promise<{
51
+ sessionId: string;
52
+ mtime: number;
53
+ }[]>;
54
+ private collection;
55
+ }
56
+ //# sourceMappingURL=mongo-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mongo-store.d.ts","sourceRoot":"","sources":["../src/mongo-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAyC,KAAK,kBAAkB,EAAE,MAAM,SAAS,CAAC;AACzF,OAAO,KAAK,EACV,UAAU,EACV,YAAY,EACZ,iBAAiB,EAClB,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,WAAW,wBAAwB;IACvC,yCAAyC;IACzC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,4CAA4C;IAC5C,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,uDAAuD;IACvD,QAAQ,CAAC,aAAa,CAAC,EAAE,kBAAkB,CAAC;CAC7C;AASD,qBAAa,iBAAkB,YAAW,YAAY;IACpD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;IACrC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,cAAc,CAA8B;gBAExC,IAAI,EAAE,wBAAwB;IAO1C,+EAA+E;IACzE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAU9B,0DAA0D;IACpD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAOtB,MAAM,CAAC,GAAG,EAAE,UAAU,EAAE,OAAO,EAAE,iBAAiB,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAqBpE,IAAI,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,iBAAiB,EAAE,GAAG,IAAI,CAAC;IAOhE,mEAAmE;IAC7D,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAM9C,2DAA2D;IACrD,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;YAUzE,UAAU;CAKzB"}
@@ -0,0 +1,79 @@
1
+ import { MongoClient } from "mongodb";
2
+ export class MongoSessionStore {
3
+ client;
4
+ databaseName;
5
+ collectionName;
6
+ connected = false;
7
+ connectPromise = null;
8
+ constructor(opts) {
9
+ if (!opts.url)
10
+ throw new Error("MongoSessionStore: url is required");
11
+ this.client = new MongoClient(opts.url, opts.clientOptions);
12
+ this.databaseName = opts.database ?? "computeragent_sessions";
13
+ this.collectionName = opts.collection ?? "sessions";
14
+ }
15
+ /** Open the connection. Idempotent; lazy callers don't need to invoke this. */
16
+ async connect() {
17
+ if (this.connected)
18
+ return;
19
+ if (!this.connectPromise) {
20
+ this.connectPromise = this.client.connect().then(() => {
21
+ this.connected = true;
22
+ });
23
+ }
24
+ await this.connectPromise;
25
+ }
26
+ /** Close the MongoClient. Safe to call multiple times. */
27
+ async close() {
28
+ if (!this.connected)
29
+ return;
30
+ this.connected = false;
31
+ this.connectPromise = null;
32
+ await this.client.close();
33
+ }
34
+ async append(key, entries) {
35
+ if (entries.length === 0)
36
+ return;
37
+ const coll = await this.collection();
38
+ const doc = await coll.findOne({ _id: key.sessionId });
39
+ const existing = doc?.entries ?? [];
40
+ const seenUuids = new Set(existing.map((e) => e.uuid).filter((u) => typeof u === "string"));
41
+ const fresh = entries.filter((e) => !e.uuid || !seenUuids.has(e.uuid));
42
+ if (fresh.length === 0)
43
+ return;
44
+ await coll.updateOne({ _id: key.sessionId }, {
45
+ $push: { entries: { $each: fresh } },
46
+ $set: { projectKey: key.projectKey, updatedAt: new Date() },
47
+ $setOnInsert: { _id: key.sessionId },
48
+ }, { upsert: true });
49
+ }
50
+ async load(key) {
51
+ const coll = await this.collection();
52
+ const doc = await coll.findOne({ _id: key.sessionId });
53
+ if (!doc?.entries || doc.entries.length === 0)
54
+ return null;
55
+ return doc.entries;
56
+ }
57
+ /** Test/admin helper: number of entries stored for a sessionId. */
58
+ async size(sessionId) {
59
+ const coll = await this.collection();
60
+ const doc = await coll.findOne({ _id: sessionId });
61
+ return doc?.entries?.length ?? 0;
62
+ }
63
+ /** Diagnostic: list the most-recently-touched sessions. */
64
+ async listSessions(projectKey) {
65
+ const coll = await this.collection();
66
+ const docs = await coll
67
+ .find({ projectKey })
68
+ .sort({ updatedAt: -1 })
69
+ .limit(50)
70
+ .toArray();
71
+ return docs.map((d) => ({ sessionId: d._id, mtime: d.updatedAt.getTime() }));
72
+ }
73
+ async collection() {
74
+ await this.connect();
75
+ const db = this.client.db(this.databaseName);
76
+ return db.collection(this.collectionName);
77
+ }
78
+ }
79
+ //# sourceMappingURL=mongo-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mongo-store.js","sourceRoot":"","sources":["../src/mongo-store.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@open-gitagent/session-store-mongo",
3
+ "version": "0.2.1",
4
+ "description": "MongoDB-backed SessionStore for the ComputerAgent harness — plug-in implementation of the Claude Agent SDK's SessionStore contract.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src"
18
+ ],
19
+ "dependencies": {
20
+ "mongodb": "^6.10.0",
21
+ "@open-gitagent/protocol": "0.2.1"
22
+ },
23
+ "devDependencies": {
24
+ "typescript": "^5.5.0",
25
+ "vitest": "^2.0.0"
26
+ },
27
+ "keywords": [
28
+ "ai-agents",
29
+ "session-store",
30
+ "mongodb",
31
+ "memory",
32
+ "computeragent"
33
+ ],
34
+ "homepage": "https://github.com/open-gitagent/ComputerAgent",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/open-gitagent/ComputerAgent.git",
38
+ "directory": "packages/session-store-mongo"
39
+ },
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.json",
42
+ "typecheck": "tsc -p tsconfig.json --noEmit",
43
+ "test": "vitest run --passWithNoTests",
44
+ "clean": "rm -rf dist .turbo *.tsbuildinfo"
45
+ }
46
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mongoSessionStoreBuilder } from "./builder.js";
3
+
4
+ describe("mongoSessionStoreBuilder", () => {
5
+ it("returns a builder that constructs a MongoSessionStore from defaults", () => {
6
+ const build = mongoSessionStoreBuilder({
7
+ url: "mongodb://localhost:27017/admin",
8
+ database: "default_db",
9
+ });
10
+ const store = build();
11
+ // Constructed without throwing — connection is lazy.
12
+ expect(store).toBeDefined();
13
+ // Cleanup so we don't leak the MongoClient.
14
+ void (store as { close?: () => Promise<void> }).close?.();
15
+ });
16
+
17
+ it("merges per-call options on top of defaults", () => {
18
+ const build = mongoSessionStoreBuilder({
19
+ url: "mongodb://localhost:27017/admin",
20
+ database: "default_db",
21
+ });
22
+ // No assertion on internal shape (private fields), but the call should not throw
23
+ // and should produce an instance — that's the contract we expose.
24
+ const store = build({ database: "override_db" });
25
+ expect(store).toBeDefined();
26
+ void (store as { close?: () => Promise<void> }).close?.();
27
+ });
28
+
29
+ it("throws if defaults.url is empty", () => {
30
+ expect(() => mongoSessionStoreBuilder({ url: "" })).toThrow();
31
+ });
32
+ });
package/src/builder.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { SessionStore } from "@open-gitagent/protocol";
2
+ import { MongoSessionStore, type MongoSessionStoreOptions } from "./mongo-store.js";
3
+
4
+ /**
5
+ * One-call builder for the harness server's `sessionStores` registry.
6
+ * Wraps `MongoSessionStore` so users can register the Mongo backend in a
7
+ * single line:
8
+ *
9
+ * createHarnessServer({
10
+ * ...,
11
+ * sessionStores: { mongo: mongoSessionStoreBuilder({ url: process.env.MONGO_URL! }) },
12
+ * });
13
+ *
14
+ * The returned builder honors per-call `options` from the wire-side
15
+ * `SessionStoreConfig.options` — useful when different sessions need
16
+ * different databases or collections under the same Mongo cluster:
17
+ *
18
+ * client side: sessionStore: { kind: "mongo", options: { database: "tenant_a" } }
19
+ *
20
+ * Per-call options merge on top of the defaults passed to this helper.
21
+ */
22
+ export function mongoSessionStoreBuilder(
23
+ defaults: MongoSessionStoreOptions,
24
+ ): (options?: unknown) => SessionStore {
25
+ if (!defaults.url) throw new Error("mongoSessionStoreBuilder: defaults.url is required");
26
+ return (options) => {
27
+ const overrides = (options ?? {}) as Partial<MongoSessionStoreOptions>;
28
+ return new MongoSessionStore({ ...defaults, ...overrides });
29
+ };
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { MongoSessionStore } from "./mongo-store.js";
2
+ export type { MongoSessionStoreOptions } from "./mongo-store.js";
3
+ export { mongoSessionStoreBuilder } from "./builder.js";
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it, beforeAll, afterAll, beforeEach } from "vitest";
2
+ import { MongoClient } from "mongodb";
3
+ import { MongoSessionStore } from "./mongo-store.js";
4
+
5
+ /**
6
+ * Integration tests against a live MongoDB. Skipped automatically when
7
+ * `MONGO_URL` is not set in the environment — keeps CI green for forks
8
+ * without a Mongo cluster while still being load-bearing when run with
9
+ * credentials.
10
+ *
11
+ * Each test uses a unique database name (`ca_test_<random>`) and drops
12
+ * it on teardown so concurrent runs don't collide.
13
+ */
14
+ const url = process.env.MONGO_URL;
15
+ const describeMongo = url ? describe : describe.skip;
16
+
17
+ describeMongo("MongoSessionStore (live)", () => {
18
+ let cleanupClient: MongoClient | null = null;
19
+ let dbName: string;
20
+ let store: MongoSessionStore;
21
+
22
+ beforeAll(async () => {
23
+ cleanupClient = new MongoClient(url!);
24
+ await cleanupClient.connect();
25
+ });
26
+
27
+ afterAll(async () => {
28
+ if (cleanupClient) await cleanupClient.close();
29
+ });
30
+
31
+ beforeEach(async () => {
32
+ dbName = `ca_test_${Math.random().toString(36).slice(2, 10)}`;
33
+ store = new MongoSessionStore({ url: url!, database: dbName });
34
+ });
35
+
36
+ const cleanup = async () => {
37
+ try { await cleanupClient?.db(dbName).dropDatabase(); } catch { /* ignore */ }
38
+ await store.close();
39
+ };
40
+
41
+ it("load() on a never-written key returns null", async () => {
42
+ try {
43
+ const r = await store.load({ projectKey: "p", sessionId: "nope" });
44
+ expect(r).toBeNull();
45
+ } finally { await cleanup(); }
46
+ });
47
+
48
+ it("append then load round-trips entries", async () => {
49
+ try {
50
+ const key = { projectKey: "p", sessionId: "s1" };
51
+ await store.append(key, [
52
+ { type: "user", uuid: "a", text: "hi" },
53
+ { type: "assistant", uuid: "b", text: "hello" },
54
+ ]);
55
+ const loaded = await store.load(key);
56
+ expect(loaded).toHaveLength(2);
57
+ expect((loaded![0] as { uuid: string }).uuid).toBe("a");
58
+ } finally { await cleanup(); }
59
+ });
60
+
61
+ it("appends accumulate across calls", async () => {
62
+ try {
63
+ const key = { projectKey: "p", sessionId: "s1" };
64
+ await store.append(key, [{ type: "u", uuid: "a" }]);
65
+ await store.append(key, [{ type: "u", uuid: "b" }]);
66
+ expect(await store.size("s1")).toBe(2);
67
+ } finally { await cleanup(); }
68
+ });
69
+
70
+ it("idempotent by entry.uuid", async () => {
71
+ try {
72
+ const key = { projectKey: "p", sessionId: "s1" };
73
+ await store.append(key, [{ type: "u", uuid: "a" }]);
74
+ await store.append(key, [{ type: "u", uuid: "a" }]);
75
+ expect(await store.size("s1")).toBe(1);
76
+ } finally { await cleanup(); }
77
+ });
78
+
79
+ it("survives across two MongoSessionStore instances (the resume scenario)", async () => {
80
+ try {
81
+ const a = store;
82
+ const key = { projectKey: "p", sessionId: "s1" };
83
+ await a.append(key, [{ type: "u", uuid: "a", text: "remember 47" }]);
84
+ const b = new MongoSessionStore({ url: url!, database: dbName });
85
+ const loaded = await b.load(key);
86
+ expect(loaded).toEqual([{ type: "u", uuid: "a", text: "remember 47" }]);
87
+ await b.close();
88
+ } finally { await cleanup(); }
89
+ });
90
+
91
+ it("different sessionIds isolated under the same database", async () => {
92
+ try {
93
+ await store.append({ projectKey: "p", sessionId: "alpha" }, [{ type: "u", uuid: "1" }]);
94
+ await store.append({ projectKey: "p", sessionId: "beta" }, [{ type: "u", uuid: "2" }]);
95
+ expect(await store.size("alpha")).toBe(1);
96
+ expect(await store.size("beta")).toBe(1);
97
+ } finally { await cleanup(); }
98
+ });
99
+
100
+ it("listSessions returns recently-written sessions for a projectKey", async () => {
101
+ try {
102
+ await store.append({ projectKey: "demo", sessionId: "x" }, [{ type: "u" }]);
103
+ await store.append({ projectKey: "demo", sessionId: "y" }, [{ type: "u" }]);
104
+ const sessions = await store.listSessions("demo");
105
+ expect(sessions.length).toBeGreaterThanOrEqual(2);
106
+ } finally { await cleanup(); }
107
+ });
108
+ });
109
+
110
+ describe("MongoSessionStore (offline contract)", () => {
111
+ it("constructor rejects empty url", () => {
112
+ expect(() => new MongoSessionStore({ url: "" })).toThrow();
113
+ });
114
+ });
@@ -0,0 +1,131 @@
1
+ import { MongoClient, type Collection, type Db, type MongoClientOptions } from "mongodb";
2
+ import type {
3
+ SessionKey,
4
+ SessionStore,
5
+ SessionStoreEntry,
6
+ } from "@open-gitagent/protocol";
7
+
8
+ /**
9
+ * MongoDB-backed SessionStore. One document per session, keyed by sessionId.
10
+ *
11
+ * Document shape:
12
+ * {
13
+ * _id: <sessionId>,
14
+ * projectKey: <string>,
15
+ * entries: [<SessionStoreEntry>, ...],
16
+ * updatedAt: <Date>,
17
+ * }
18
+ *
19
+ * Idempotency by entry.uuid: a duplicate uuid append is skipped. Currently
20
+ * implemented with a load-then-write pattern, which is correct for single-
21
+ * writer flows (the harness server is single-writer per session). Multi-
22
+ * writer correctness requires either an aggregation-pipeline update or a
23
+ * lock; documented limitation.
24
+ *
25
+ * Lifecycle: pass a `mongoUrl` to construct, call `connect()` once before
26
+ * first use (lazy: `append`/`load` will auto-connect on first call), and
27
+ * `close()` when done.
28
+ */
29
+ export interface MongoSessionStoreOptions {
30
+ /** Mongo connection string. Required. */
31
+ readonly url: string;
32
+ /** Database name. Default: `computeragent_sessions`. */
33
+ readonly database?: string;
34
+ /** Collection name. Default: `sessions`. */
35
+ readonly collection?: string;
36
+ /** Optional MongoClient options forwarded verbatim. */
37
+ readonly clientOptions?: MongoClientOptions;
38
+ }
39
+
40
+ interface SessionDoc {
41
+ _id: string;
42
+ projectKey: string;
43
+ entries: SessionStoreEntry[];
44
+ updatedAt: Date;
45
+ }
46
+
47
+ export class MongoSessionStore implements SessionStore {
48
+ private readonly client: MongoClient;
49
+ private readonly databaseName: string;
50
+ private readonly collectionName: string;
51
+ private connected = false;
52
+ private connectPromise: Promise<void> | null = null;
53
+
54
+ constructor(opts: MongoSessionStoreOptions) {
55
+ if (!opts.url) throw new Error("MongoSessionStore: url is required");
56
+ this.client = new MongoClient(opts.url, opts.clientOptions);
57
+ this.databaseName = opts.database ?? "computeragent_sessions";
58
+ this.collectionName = opts.collection ?? "sessions";
59
+ }
60
+
61
+ /** Open the connection. Idempotent; lazy callers don't need to invoke this. */
62
+ async connect(): Promise<void> {
63
+ if (this.connected) return;
64
+ if (!this.connectPromise) {
65
+ this.connectPromise = this.client.connect().then(() => {
66
+ this.connected = true;
67
+ });
68
+ }
69
+ await this.connectPromise;
70
+ }
71
+
72
+ /** Close the MongoClient. Safe to call multiple times. */
73
+ async close(): Promise<void> {
74
+ if (!this.connected) return;
75
+ this.connected = false;
76
+ this.connectPromise = null;
77
+ await this.client.close();
78
+ }
79
+
80
+ async append(key: SessionKey, entries: SessionStoreEntry[]): Promise<void> {
81
+ if (entries.length === 0) return;
82
+ const coll = await this.collection();
83
+ const doc = await coll.findOne({ _id: key.sessionId });
84
+ const existing = doc?.entries ?? [];
85
+ const seenUuids = new Set(
86
+ existing.map((e) => e.uuid).filter((u): u is string => typeof u === "string"),
87
+ );
88
+ const fresh = entries.filter((e) => !e.uuid || !seenUuids.has(e.uuid));
89
+ if (fresh.length === 0) return;
90
+ await coll.updateOne(
91
+ { _id: key.sessionId },
92
+ {
93
+ $push: { entries: { $each: fresh } as never },
94
+ $set: { projectKey: key.projectKey, updatedAt: new Date() },
95
+ $setOnInsert: { _id: key.sessionId } as never,
96
+ },
97
+ { upsert: true },
98
+ );
99
+ }
100
+
101
+ async load(key: SessionKey): Promise<SessionStoreEntry[] | null> {
102
+ const coll = await this.collection();
103
+ const doc = await coll.findOne({ _id: key.sessionId });
104
+ if (!doc?.entries || doc.entries.length === 0) return null;
105
+ return doc.entries;
106
+ }
107
+
108
+ /** Test/admin helper: number of entries stored for a sessionId. */
109
+ async size(sessionId: string): Promise<number> {
110
+ const coll = await this.collection();
111
+ const doc = await coll.findOne({ _id: sessionId });
112
+ return doc?.entries?.length ?? 0;
113
+ }
114
+
115
+ /** Diagnostic: list the most-recently-touched sessions. */
116
+ async listSessions(projectKey: string): Promise<{ sessionId: string; mtime: number }[]> {
117
+ const coll = await this.collection();
118
+ const docs = await coll
119
+ .find({ projectKey })
120
+ .sort({ updatedAt: -1 })
121
+ .limit(50)
122
+ .toArray();
123
+ return docs.map((d) => ({ sessionId: d._id, mtime: d.updatedAt.getTime() }));
124
+ }
125
+
126
+ private async collection(): Promise<Collection<SessionDoc>> {
127
+ await this.connect();
128
+ const db: Db = this.client.db(this.databaseName);
129
+ return db.collection<SessionDoc>(this.collectionName);
130
+ }
131
+ }