Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/state-kernel-sqlite-driver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@agent-bundle/runtime": minor
---

Ship the workspace-durable state driver on `node:sqlite` (#98 v1, G3)
behind the dedicated `./state/sqlite` subpath: WAL journal mode with full
synchronous durability, every commit in one immediate transaction
(idempotency lookup, compare-and-swap, reducer, journal append, head update
commit atomically), cross-process writers serialized on the database lock
with a bounded busy timeout, explicit migrations on open, and corruption
failing closed with typed errors. The driver passes the same conformance
suite as the in-memory driver, plus cross-process proofs: two independent
processes updating one store, and a SIGKILLed writer never leaving a
successful-but-corrupt state. The subpath split keeps `node:sqlite` (and
its ExperimentalWarning) away from volatile-state and stateless consumers,
and the package now declares `"sideEffects": false` so bundlers can
tree-shake unused kernel exports.
4 changes: 2 additions & 2 deletions docs/architecture/rsc-runtime-workbench.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ examples/
src/flight/request-render.ts
src/hook/cli.ts
src/hook/normalize.ts
src/hook/project-document.ts
src/mcp/create-server.ts
src/mcp/handlers.ts
src/mcp/host-metadata.ts
Expand All @@ -157,8 +158,7 @@ examples/
src/rsc/routes.tsx
src/rsc/worker.tsx
src/runtime/contracts.ts
src/runtime/state-file-core.ts
src/runtime/state-file-test-support.ts
src/runtime/state-definition.ts
src/runtime/state-file.ts
src/types/mcp-ext-apps-react.d.ts
src/types/react-server-dom-rspack.d.ts
Expand Down
37 changes: 26 additions & 11 deletions examples/rsc-agent-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This private, opt-in example shows one React Server Components (RSC) runtime sha
| Plane | Responsibility | Lifetime |
| --- | --- | --- |
| Definition | Static hook matchers, tool schemas, resource URIs, and metadata | Build/startup |
| Kernel | Append-only JSONL events and snapshots | Cross-process |
| Kernel | Framework state kernel (#98): typed events, monotonic revisions, workspace-durable `node:sqlite` storage | Cross-process |
| RSC render | Hook and MCP result component trees, lowered from Flight | One request |
| MCP App UI | Mounted timeline, Refresh, and recoverable row selection | One UI instance |

Expand Down Expand Up @@ -96,7 +96,7 @@ pnpm --filter @agent-bundle/rsc-agent-runtime-demo exec agent-bundle build --jso
To exercise one hook manually, give it an explicit state file and native Claude-shaped JSON:

```bash
AGENT_RUNTIME_STATE_FILE=/tmp/rsc-events.jsonl \
AGENT_RUNTIME_STATE_FILE=/tmp/rsc-agent-state.sqlite \
node examples/rsc-agent-runtime/dist/runtime/hook/index.js --host claude <<JSON
{"hook_event_name":"PostToolUse","session_id":"manual","cwd":"$PWD","tool_name":"Write","tool_input":{"file_path":"README.md"}}
JSON
Expand All @@ -105,14 +105,14 @@ JSON
Run the built stdio MCP server with the same state file:

```bash
AGENT_RUNTIME_STATE_FILE=/tmp/rsc-events.jsonl \
AGENT_RUNTIME_STATE_FILE=/tmp/rsc-agent-state.sqlite \
node examples/rsc-agent-runtime/dist/runtime/mcp/stdio.js
```

Run the Streamable HTTP server locally at `/mcp`:

```bash
AGENT_RUNTIME_STATE_FILE=/tmp/rsc-events.jsonl PORT=3000 \
AGENT_RUNTIME_STATE_FILE=/tmp/rsc-agent-state.sqlite PORT=3000 \
node examples/rsc-agent-runtime/dist/runtime/mcp/http.js
```

Expand Down Expand Up @@ -150,11 +150,12 @@ pnpm eval:spot
```

It builds this example, replays one native-shaped Claude `PostToolUse` event
through the built hook binary (RSC worker render, Flight lowering, durable
JSONL kernel write), then connects a real stdio MCP client to the built server
over the same state file and asserts the RSC-lowered `render_edit_timeline`
result, the shared edit snapshot, and the linked MCP App resource. It contacts
no real host and needs no credentials.
through the built hook binary (RSC worker render, Flight lowering, a durable
state-kernel commit), replays the same native tool id from a second hook
process to prove cross-process idempotency, then connects a real stdio MCP
client to the built server over the same state file and asserts the
RSC-lowered `render_edit_timeline` result, the shared edit snapshot, and the
linked MCP App resource. It contacts no real host and needs no credentials.

To exercise the Claude package manually when an external host run is separately
authorized, use its native shell contract:
Expand Down Expand Up @@ -206,7 +207,7 @@ separately captures a real public HTTPS-host result.
The widget is locally tested over its MCP Apps resource/HTTP/browser path. To connect it to ChatGPT Developer Mode, expose local `/mcp` through a public HTTPS tunnel and allow that exact public host/origin before starting the server:

```bash
AGENT_RUNTIME_STATE_FILE=/tmp/rsc-events.jsonl \
AGENT_RUNTIME_STATE_FILE=/tmp/rsc-agent-state.sqlite \
AGENT_RUNTIME_ALLOWED_HOSTS=tunnel.example \
AGENT_RUNTIME_ALLOWED_ORIGINS=https://tunnel.example \
AGENT_RUNTIME_PUBLIC_MCP_URL=https://tunnel.example/mcp \
Expand Down Expand Up @@ -237,7 +238,21 @@ For ordinary MCP Apps, prefer Agent Bundle's standard non-RSC `mcp.servers.<serv

## Limits and opt-in boundary

The demo kernel is append-only JSONL: it is appropriate for a small local example, not concurrent/distributed production storage. The RSC-facing packages are exact pins because their framework-facing surface is not treated as stable here: React `19.2.8`, `react-dom` `19.2.8`, `react-server-dom-rspack` `0.1.0`, Rsbuild `2.2.1`, and `rsbuild-plugin-rsc` `0.1.1`.
Durable state rides the framework state kernel's workspace-durable driver
(`@agent-bundle/runtime/state/sqlite`, issue #98): one SQLite database per
workspace on Node's built-in `node:sqlite`, with WAL journaling, transactional
commits, idempotency-key replay, and exact-revision reads. The retired
hand-rolled JSONL kernel is gone; this example now only declares its schema,
events, and reducer. Loading the sqlite driver emits Node's one-time
`ExperimentalWarning: SQLite is an experimental feature` on stderr — expected
on the supported Node lines (`node:sqlite` needs no flag on Node >= 22.13),
harmless for hooks and MCP servers (protocol output uses stdout), and absent
for stateless consumers because the driver lives behind its own subpath. It is
suitable local single-workspace storage, not concurrent/distributed production
storage. The RSC-facing packages are exact pins because their framework-facing
surface is not treated as stable here: React `19.2.8`, `react-dom` `19.2.8`,
`react-server-dom-rspack` `0.1.0`, Rsbuild `2.2.1`, and `rsbuild-plugin-rsc`
`0.1.1`.

Existing Agent Bundle skills, static MCPs, evaluations, and normal hooks neither require nor activate this runtime. Nothing under `packages/agent-bundle` imports the example or React/RSC runtime packages.

Expand Down
2 changes: 0 additions & 2 deletions examples/rsc-agent-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
"@modelcontextprotocol/ext-apps": "1.7.5",
"@modelcontextprotocol/sdk": "1.30.0",
"express": "5.2.1",
"proper-lockfile": "^4.1.2",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-server-dom-rspack": "0.1.0",
Expand All @@ -27,7 +26,6 @@
"@rsbuild/plugin-react": "2.1.0",
"@rstest/core": "0.11.10",
"@types/express": "5.0.6",
"@types/proper-lockfile": "^4.1.4",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"agent-bundle": "workspace:*",
Expand Down
28 changes: 24 additions & 4 deletions examples/rsc-agent-runtime/scripts/eval-hosts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { copyFile, chmod, mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/pro
import { once } from 'node:events';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';

import { classifyNativeEvidence, evidenceFromTranscript, hookEvidenceFromProbe, summarizeHookProbe } from './eval-evidence.mjs';
import { sanitizedHostEnvironment } from './eval-host-environment.mjs';
Expand Down Expand Up @@ -65,10 +66,29 @@ const hookProbeSummary = async (probeFile) => {
return summarizeHookProbe(records);
};

/**
* Reads committed edit events from the framework state kernel's sqlite
* journal (#98) in the legacy record shape `evidenceFromTranscript` matches
* on. Read-only: host evidence collection never mutates runtime state.
*/
const readStateRecords = (stateFile) => {
try {
const db = new DatabaseSync(stateFile, { readOnly: true });
try {
return db
.prepare("SELECT payload FROM agent_state_journal WHERE kind = 'event' ORDER BY revision")
.all()
.map(({ payload }) => ({ event: JSON.parse(payload), kind: 'edit' }));
} finally {
db.close();
}
} catch {
return [];
}
};

const evidenceFrom = async (host, fixture, stateFile, probeFile, transcript, correlation) => {
const stateRecords = await readFile(stateFile, 'utf8')
.then((contents) => contents.split('\n').filter(Boolean).map((line) => JSON.parse(line)))
.catch(() => []);
const stateRecords = readStateRecords(stateFile);
const transcriptEvidence = evidenceFromTranscript(host, transcript, { ...correlation, stateRecords });
const editObserved = await stat(join(fixture, correlation.editPath)).then(() => true).catch(() => false);
const hookProbe = await hookProbeSummary(probeFile);
Expand Down Expand Up @@ -102,7 +122,7 @@ const evaluateHost = async (host, capturedAt) => {
finalMarker: `HOST_EVAL_FINAL host=${host} marker=${marker}`,
marker,
};
const stateFile = join(fixture, '.agent-runtime-demo', 'events.jsonl');
const stateFile = join(fixture, '.agent-runtime-demo', 'state.sqlite');
const probeFile = join(fixture, 'hook-probe.jsonl');
const sharedEnv = sanitizedHostEnvironment(process.env, { hookProbeFile: probeFile, stateFile });
let temporaryCodexHome;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession {
this.#activationPhaseBudgetMs = input.testing.activationPhaseBudgetMs ?? defaultActivationPhaseBudgetMs;
this.#ownedRunsRoot = input.ownedRunsRoot;
this.#runRoot = input.ownedRunsRoot.root;
this.#stateFile = join(resolve(input.context.storageRoot), 'state', `${stateStoreId}.jsonl`);
this.#stateFile = join(resolve(input.context.storageRoot), 'state', `${stateStoreId}.sqlite`);
this.#stateKernel = createFileRuntimeKernel({ stateFile: this.#stateFile });
this.#preparedRevisions.add(input.preparedRuntime.sourceRevision);
this.#status = Object.freeze({
Expand Down
14 changes: 0 additions & 14 deletions examples/rsc-agent-runtime/src/runtime/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,6 @@ export type JsonValue =
| readonly JsonValue[]
| Readonly<{ [key: string]: JsonValue }>;

export type RuntimeStateRecord =
| Readonly<{
event: EditEvent;
idempotencyKey: string;
kind: 'edit';
stateVersion: number;
}>
| Readonly<{
idempotencyKey: string;
kind: 'reset';
seed?: JsonValue;
stateVersion: number;
}>;

export interface RuntimeSnapshot {
stateVersion: number;
edits: EditEvent[];
Expand Down
80 changes: 80 additions & 0 deletions examples/rsc-agent-runtime/src/runtime/state-definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { defineState } from '@agent-bundle/runtime/state';
import { z } from 'zod';

import type { JsonValue } from './contracts.js';

/**
* The edit-timeline state, declared once against the framework state kernel
* (#98). This replaces the example's retired hand-rolled JSONL kernel
* (`state-file-core.ts`): the framework owns revisions, idempotency
* replay/conflict, atomicity, exact-revision reads, migrations, and
* corruption fail-closed behavior; the example declares its schema, events,
* and pure reducer.
*
* The event payload carries exactly the caller-owned semantic fields —
* host, path, sessionId, toolName — which makes the kernel's whole-payload
* idempotency identity match the retired kernel's canonical dedupe input.
* Presentation fields are derived, not stored: `eventId` comes from the
* committed revision and `recordedAt` from the journal's commit timestamp
* (see `state-file.ts`), so retries of one native tool event replay cleanly
* instead of conflicting over generated values.
*/

const nonEmpty = (): z.ZodType<string> => z.string().refine((value) => value.trim() !== '', 'must be non-empty');

export const RecordedEditSchema = z
.object({
host: z.enum(['claude', 'codex']),
path: nonEmpty(),
sessionId: nonEmpty(),
toolName: nonEmpty(),
})
.strict();

export type RecordedEdit = z.output<typeof RecordedEditSchema>;

const JsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
z.union([
z.null(),
z.boolean(),
z.number().finite(),
z.string(),
z.array(JsonValueSchema),
z.record(z.string(), JsonValueSchema),
]),
);

const TimelineStateSchema = z
.object({
edits: z.array(RecordedEditSchema),
seed: JsonValueSchema.optional(),
})
.strict();

export type EditTimelineState = z.output<typeof TimelineStateSchema>;

const timelineEvents = {
editRecorded: RecordedEditSchema,
} as const;

export type EditTimelineEvents = typeof timelineEvents;

export const editTimelineDefinition = defineState({
events: timelineEvents,
id: 'rsc-agent-runtime/edit-timeline',
initial: { edits: [] },
lifetime: 'workspace-durable',
reduce: (state, event): EditTimelineState => {
switch (event.name) {
case 'editRecorded':
return state.seed === undefined
? { edits: [...state.edits, event.payload] }
: { edits: [...state.edits, event.payload], seed: state.seed };
default: {
const unreachable: never = event.name;
throw new Error(`Unhandled edit-timeline event ${String(unreachable)}`);
}
}
},
schema: TimelineStateSchema,
});
Loading
Loading