@godspeedai/cognate-runtime-bun 0.1.2 → 0.2.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The Cognate reference runtime for Bun: durable agent runs, human-in-the-loop continuations,
4
4
  artifacts, projections, and optional cognition, composed in-process behind a single
5
- `RuntimeService`. Point it at a SQLite file and your application's history survives restarts.
5
+ `RuntimeService`. Point it at a SQLite file and your application's history survives restarts; point it at PostgreSQL (or a pg0-managed local instance) and multiple processes share the same history safely.
6
6
 
7
7
  **When to use this package:** to run agents for real — in a server, a worker, or a script. Pair it
8
8
  with [`@godspeedai/cognate-client`](https://www.npmjs.com/package/@godspeedai/cognate-client) to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godspeedai/cognate-runtime-bun",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Bun reference runtime: composes kernel, durable store, runs, continuations, commitments, artifacts, and cognition behind RuntimeService.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,17 +22,18 @@
22
22
  ".": "./src/index.ts"
23
23
  },
24
24
  "dependencies": {
25
- "@godspeedai/cognate-artifacts": "^0.1.2",
26
- "@godspeedai/cognate-capabilities": "^0.1.2",
27
- "@godspeedai/cognate-cognitive-api": "^0.1.2",
28
- "@godspeedai/cognate-continuations": "^0.1.2",
29
- "@godspeedai/cognate-events": "^0.1.2",
30
- "@godspeedai/cognate-kernel-api": "^0.1.2",
31
- "@godspeedai/cognate-kernel-cordis": "^0.1.2",
32
- "@godspeedai/cognate-projections": "^0.1.2",
33
- "@godspeedai/cognate-runtime-api": "^0.1.2",
34
- "@godspeedai/cognate-store-memory": "^0.1.2",
35
- "@godspeedai/cognate-store-sqlite": "^0.1.2"
25
+ "@godspeedai/cognate-artifacts": "^0.2.0",
26
+ "@godspeedai/cognate-capabilities": "^0.2.0",
27
+ "@godspeedai/cognate-cognitive-api": "^0.2.0",
28
+ "@godspeedai/cognate-continuations": "^0.2.0",
29
+ "@godspeedai/cognate-events": "^0.2.0",
30
+ "@godspeedai/cognate-kernel-api": "^0.2.0",
31
+ "@godspeedai/cognate-kernel-cordis": "^0.2.0",
32
+ "@godspeedai/cognate-projections": "^0.2.0",
33
+ "@godspeedai/cognate-runtime-api": "^0.2.0",
34
+ "@godspeedai/cognate-store-memory": "^0.2.0",
35
+ "@godspeedai/cognate-store-postgres": "^0.2.0",
36
+ "@godspeedai/cognate-store-sqlite": "^0.2.0"
36
37
  },
37
38
  "devDependencies": {
38
39
  "@godspeedai/cognate-cognitive-memory": "workspace:*"
package/src/index.ts CHANGED
@@ -34,9 +34,20 @@ export type PendingEvent = NewEvent & { readonly eventId: string; readonly strea
34
34
  /** Outbox topic whose items represent RealityTrace observations; `inspect` reports their observation ids. */
35
35
  export const OBSERVATION_OUTBOX_TOPIC = "observation.deliver";
36
36
 
37
+ /** Where the runtime's authoritative state lives (spec §13). SQLite stays the zero-dependency default;
38
+ * PostgreSQL removes the single-writer limit (multi-process runtimes share one database). */
39
+ export type RuntimeStoreConfig =
40
+ /** SQLite database file path, or `":memory:"` for an in-memory SQLite database. */
41
+ | string
42
+ | { readonly store: DurableStore }
43
+ | { readonly sqlite: { readonly path: string } }
44
+ | { readonly postgres: { readonly url: string; readonly schema?: string } }
45
+ | { readonly postgresPg0: { readonly name?: string; readonly schema?: string; readonly bin?: string } };
46
+
37
47
  export interface RuntimeOptions {
38
- /** SQLite file path, `":memory:"` for an in-memory SQLite database, or an explicit store. */
39
- readonly store: string | { readonly store: DurableStore };
48
+ /** Where durable state lives: a SQLite path (default form), an explicit store, a PostgreSQL
49
+ * connection (`postgres`), or a pg0-managed local instance (`postgresPg0` starts it if needed). */
50
+ readonly store: RuntimeStoreConfig;
40
51
  /** Kernel invocation policy for capabilities (default-deny if omitted). */
41
52
  readonly policy: Policy;
42
53
  readonly actions: ActionPolicy;
package/src/runtime.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  // Composition root: store, kernel, continuations, artifacts, projections, cognition, and the background loop.
2
2
  import { ArtifactService } from "@godspeedai/cognate-artifacts";
3
3
  import { ContinuationService } from "@godspeedai/cognate-continuations";
4
- import { type DurableStore, OutboxDispatcher } from "@godspeedai/cognate-events";
4
+ import { type DurableStore, type Json, NotFoundError, OutboxDispatcher } from "@godspeedai/cognate-events";
5
5
  import { createCordisKernel } from "@godspeedai/cognate-kernel-cordis";
6
6
  import type { Principal } from "@godspeedai/cognate-kernel-api";
7
7
  import { ProjectionRunner } from "@godspeedai/cognate-projections";
8
+ import { openPostgresStore, resolvePg0Url } from "@godspeedai/cognate-store-postgres";
8
9
  import { openSqliteStore } from "@godspeedai/cognate-store-sqlite";
9
10
  import { COGNITION_PROJECTION, cognitionProjection, recallFor } from "./cognition.ts";
10
11
  import type { Core } from "./core.ts";
@@ -21,8 +22,23 @@ const DELIVERY = { baseMs: 50, maxMs: 5_000, maxAttempts: 10 };
21
22
  const TOPICS = [RUN_EXECUTE_TOPIC, "continuation.resume"];
22
23
 
23
24
  export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
24
- const ownsStore = typeof options.store === "string";
25
- const store: DurableStore = typeof options.store === "string" ? openSqliteStore({ path: options.store }) : options.store.store;
25
+ // The runtime closes only stores it opened itself; an injected store is owned by its creator.
26
+ // (The PostgreSQL store's close() ends its pool and LISTEN connection, so one call covers both.)
27
+ const config = options.store;
28
+ const ownsStore = typeof config === "string" || !("store" in config);
29
+ let store: DurableStore;
30
+ if (typeof config === "string") {
31
+ store = openSqliteStore({ path: config });
32
+ } else if ("store" in config) {
33
+ store = config.store;
34
+ } else if ("sqlite" in config) {
35
+ store = openSqliteStore({ path: config.sqlite.path });
36
+ } else if ("postgres" in config) {
37
+ store = (await openPostgresStore({ url: config.postgres.url, schema: config.postgres.schema })).store;
38
+ } else {
39
+ const url = await resolvePg0Url(config.postgresPg0);
40
+ store = (await openPostgresStore({ url, schema: config.postgresPg0.schema })).store;
41
+ }
26
42
  const system: Principal = options.system ?? { id: "system", kind: "system" };
27
43
  const governor = new Governor(options.actions, options.policy, options.governance ?? { mode: "off" });
28
44
  const invoker = new Invoker(governor);
@@ -136,6 +152,107 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
136
152
  }
137
153
  }
138
154
 
155
+ // ── Erasure propagation (spec §32, DEBT-014) ─────────────────────────────────────
156
+ // Erasing one event must also reach its derived copies: idempotency result snapshots (redacted by
157
+ // the store's erasePayload itself), continuation records and their transition events, run.resumed
158
+ // resolution copies on the run stream, and recorded run.recall results referencing the erased
159
+ // run's view. Every erasure is recorded as a durable job before it runs, so an interrupted
160
+ // propagation resumes on the next erase call or runtime start instead of being reported complete.
161
+ const ERASURES = "erasures";
162
+
163
+ interface ErasureJob {
164
+ readonly id: string;
165
+ readonly streamId: string | null;
166
+ readonly runId: string | null;
167
+ readonly eventType: string | null;
168
+ readonly reason: string;
169
+ }
170
+
171
+ async function propagateErasure(job: ErasureJob): Promise<void> {
172
+ const event = await store.getEvent(job.id);
173
+ if (event && !event.erasure) await store.erasePayload(job.id, job.reason, "erasure");
174
+ if (!job.runId) return;
175
+ await continuations.redactForRun(job.runId, { reason: job.reason });
176
+ // run.resumed events carry the resolution-input copy on the run stream itself.
177
+ for (const e of await store.readStream(runStream(job.runId))) {
178
+ if (e.eventType === "run.resumed" && !e.erasure) await store.erasePayload(e.eventId, job.reason, "erasure");
179
+ }
180
+ // A recorded run.recall whose items reference an erased run's view copies that view's source
181
+ // content (run.started input, run.completed output). Lineage is explicit (sourceRef), not a text
182
+ // guess. The closure is transitive: a recalling run's own terminal output may embed the recalled
183
+ // result (the agent returned it), and a third run may have recalled THAT view — so each erased
184
+ // terminal output makes its run a view source too. Visited runs terminate the loop.
185
+ if (job.eventType !== "run.started" && job.eventType !== "run.completed") return;
186
+ const viewSources: string[] = [job.runId];
187
+ const visited = new Set<string>();
188
+ while (viewSources.length > 0) {
189
+ const sourceRunId = viewSources.shift()!;
190
+ if (visited.has(sourceRunId)) continue;
191
+ visited.add(sourceRunId);
192
+ let afterPosition = 0;
193
+ for (;;) {
194
+ const page = await store.readAll({ afterPosition, limit: 500 });
195
+ if (page.length === 0) break;
196
+ for (const e of page) {
197
+ afterPosition = e.position;
198
+ if (e.eventType !== "run.recall" || e.erasure || !e.runId) continue;
199
+ const items = (e.payload as { readonly result?: { readonly items?: readonly { readonly sourceRef?: string }[] } } | null)?.result?.items;
200
+ if (!Array.isArray(items) || !items.some((item) => item.sourceRef === `run:${sourceRunId}`)) continue;
201
+ await store.erasePayload(e.eventId, job.reason, "erasure");
202
+ // The recalling run's terminal output may embed the recalled content; erase it and treat
203
+ // that run as a view source for its own recallers.
204
+ for (const terminal of await store.readStream(runStream(e.runId))) {
205
+ if ((terminal.eventType === "run.completed" || terminal.eventType === "run.failed") && !terminal.erasure) {
206
+ await store.erasePayload(terminal.eventId, job.reason, "erasure");
207
+ }
208
+ }
209
+ viewSources.push(e.runId);
210
+ }
211
+ if (page.length < 500) break;
212
+ }
213
+ }
214
+ }
215
+
216
+ async function completeErasureJob(job: { readonly id: string; readonly version: number; readonly value: Record<string, Json> }): Promise<void> {
217
+ await store.commit({
218
+ appends: [],
219
+ records: [{ collection: ERASURES, id: job.id, expectedVersion: job.version, value: { ...job.value, status: "complete", completedAt: new Date().toISOString() } }],
220
+ });
221
+ }
222
+
223
+ async function resumeErasureJobs(): Promise<void> {
224
+ for (const stored of await store.listRecords(ERASURES, { status: "propagating" })) {
225
+ const value = stored.value;
226
+ await propagateErasure({
227
+ id: stored.id,
228
+ streamId: (value.streamId as string | null) ?? null,
229
+ runId: (value.runId as string | null) ?? null,
230
+ eventType: (value.eventType as string | null) ?? null,
231
+ reason: value.reason as string,
232
+ });
233
+ await completeErasureJob(stored);
234
+ }
235
+ }
236
+
237
+ async function eraseWithPropagation(eventId: string, reason: string, actor: Principal): Promise<void> {
238
+ await resumeErasureJobs();
239
+ const target = await store.getEvent(eventId);
240
+ if (!target) throw new NotFoundError(`event ${eventId} not found`);
241
+ let job = await store.getRecord(ERASURES, eventId);
242
+ if (!job) {
243
+ await store.commit({
244
+ appends: [],
245
+ records: [{ collection: ERASURES, id: eventId, expectedVersion: 0, value: { status: "propagating", reason, actorId: actor.id, streamId: target.streamId, runId: target.runId, eventType: target.eventType, startedAt: new Date().toISOString() } }],
246
+ });
247
+ job = await store.getRecord(ERASURES, eventId);
248
+ }
249
+ if (!job) throw new Error(`erase: could not record the propagation job for ${eventId}`);
250
+ await propagateErasure({ id: eventId, streamId: target.streamId, runId: target.runId, eventType: target.eventType, reason });
251
+ await completeErasureJob(job);
252
+ }
253
+
254
+ await resumeErasureJobs();
255
+
139
256
  const timer = setInterval(kick, options.tickMs ?? 10);
140
257
  kick();
141
258
 
@@ -203,7 +320,7 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
203
320
  },
204
321
 
205
322
  async erase(eventId, reason, actor) {
206
- await store.erasePayload(eventId, reason, actor.id);
323
+ await eraseWithPropagation(eventId, reason, actor);
207
324
  },
208
325
 
209
326
  pollApprovals,
package/src/service.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // In-process RuntimeService (ADR-0011): authorization, tenancy, and typed errors over the durable core.
2
2
  import { randomUUID } from "node:crypto";
3
3
  import type { ContinuationRecord, TransitionOutcome } from "@godspeedai/cognate-continuations";
4
- import { ConcurrencyError, fingerprint, IdempotencyMismatchError, type Json, type StoredEvent } from "@godspeedai/cognate-events";
4
+ import { ConcurrencyError, fingerprint, IdempotencyMismatchError, readProjectionSnapshot, type Json, type StoredEvent } from "@godspeedai/cognate-events";
5
5
  import { applyStateUpdate, type Caller, type ContinuationView, foldState, type ObservationInput, type ObservationView, type RunView, type RuntimeService, ServiceError, type ServiceErrorCode, StatePatchError } from "@godspeedai/cognate-runtime-api";
6
6
  import type { Core } from "./core.ts";
7
7
  import type { ActionName } from "./governed-operation.ts";
@@ -218,9 +218,15 @@ export function createService(core: Core, publicProjections: ReadonlySet<string>
218
218
  async readProjection(caller, request) {
219
219
  if (!publicProjections.has(request.name)) throw new ServiceError("not_found", `projection ${request.name} not found`);
220
220
  // Public projections are tenant-partitioned by key prefix `<tenant>/` (spec §32); an empty
221
- // partition is [] so the caller cannot tell whether other tenants have data.
221
+ // partition is [] so the caller cannot tell whether other tenants have data. The checkpoint is
222
+ // read consistently with the rows (never ahead of them) so a client can tell whether its view
223
+ // covers the events it has observed (DEBT-021).
222
224
  const prefix = `${caller.tenant}/`;
223
- return (await store.listProjection(request.name)).filter((e) => e.key.startsWith(prefix)).map((e) => ({ key: e.key.slice(prefix.length), value: e.value }));
225
+ const snapshot = await readProjectionSnapshot(store, request.name);
226
+ return {
227
+ rows: snapshot.rows.filter((e) => e.key.startsWith(prefix)).map((e) => ({ key: e.key.slice(prefix.length), value: e.value })),
228
+ checkpoint: snapshot.checkpoint ?? null,
229
+ };
224
230
  },
225
231
 
226
232
  async listRuns(caller, request) {