mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 01:45:32 +00:00
fix(environment): refuse writes the state could not hold or reload
Served writes now refuse, before changing the state: - a document write with members or JSON types its plan did not accept (the same body check entity writes use), or at a path other than the one the state holds the document at; - a body or resulting value data/add would refuse (a number inexact as data/get writes it, a __proto__ member, nesting past the limit), so data/get output always loads back, increments included; - a create, update or document write that would take the state past the 32 MiB data/add intake limit (resource_constraint), with the state's serialized size kept incrementally rather than reserialized. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
138ea4b198
commit
6d21b62e84
7 files changed
+500
-109
No files matched your search
@@ -6,6 +6,7 @@ import {
|
||||
same,
|
||||
scalarText,
|
||||
segmentsOf,
|
||||
type JsonObject,
|
||||
type StateDocument,
|
||||
type StateEntity,
|
||||
type StateType,
|
||||
@@ -15,14 +16,22 @@ import {
|
||||
type AnalysisInputIssue,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { canonicalJson } from "../../domain/comparisonSemantics.js";
|
||||
import { parseExactJson } from "../../domain/exactJsonNumber.js";
|
||||
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
jsonValueSchema,
|
||||
jsonValueValidationIssue,
|
||||
type JsonValue,
|
||||
} from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
|
||||
import type { EnvironmentState } from "./environmentState.js";
|
||||
import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js";
|
||||
import { REA_STATE_FORMAT } from "./environmentTools.js";
|
||||
|
||||
/** A loaded state document is at most this large, whether it arrives as bytes, a file or a download. */
|
||||
export const STATE_INTAKE_LIMIT_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/** A document as the state holds it; only a value equal to the Evidence's cites the record it came from. */
|
||||
export type HeldDocument = Omit<StateDocument, "record_ordinal"> & {
|
||||
readonly record_ordinal?: number;
|
||||
@@ -131,6 +140,18 @@ const prototypeMemberPath = (root: unknown): Path | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Whether `data/add` accepts a value as a `fields` member or a document's `value`: bounded nesting, no `__proto__` member, and numbers exact as `data/get` writes them. */
|
||||
export const isLoadableValue = (value: JsonValue): boolean =>
|
||||
jsonValueValidationIssue(value) === undefined &&
|
||||
prototypeMemberPath(value) === undefined &&
|
||||
parseExactJson(JSON.stringify(value)) !== undefined;
|
||||
|
||||
/** The first member of an object `data/add` would refuse as `fields`, if any: a `__proto__` member, or one whose value does not load. */
|
||||
export const unloadableMember = (object: JsonObject): string | undefined =>
|
||||
Object.keys(object).find(
|
||||
(member) => member === "__proto__" || !isLoadableValue(object[member]!),
|
||||
);
|
||||
|
||||
/** The first schema problem, located without naming the caller's data. */
|
||||
const schemaProblem = (
|
||||
issues: readonly z.core.$ZodIssue[],
|
||||
|
||||
@@ -2,13 +2,18 @@ import type {
|
||||
StateEntity,
|
||||
StateType,
|
||||
} from "../../domain/analysisView/environmentStateRules.js";
|
||||
import type { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisResourceConstraintError,
|
||||
type AnalysisInputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { ok, type Result } from "../../domain/result.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
|
||||
import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js";
|
||||
import type { EnvironmentState } from "./environmentState.js";
|
||||
import {
|
||||
parseStateDocument,
|
||||
STATE_INTAKE_LIMIT_BYTES,
|
||||
type HeldDocument,
|
||||
type StateContent,
|
||||
} from "./environmentStateDocument.js";
|
||||
@@ -24,10 +29,20 @@ export interface StateSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
const jsonBytes = (value: unknown): number =>
|
||||
Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||
|
||||
/** The bytes JSON writes for these array items and the commas between them. */
|
||||
const itemsBytes = (items: readonly unknown[]): number =>
|
||||
items.reduce<number>((sum, item) => sum + jsonBytes(item), 0) +
|
||||
Math.max(0, items.length - 1);
|
||||
|
||||
/**
|
||||
* The live state one served environment answers from, shared by every MCP
|
||||
* session and the data plane. Mutations are synchronous and swap the whole
|
||||
* state, so every answer reads one consistent state.
|
||||
* state, so every answer reads one consistent state. A mutation that would take
|
||||
* what `data/get` reports past the size `data/add` loads is refused, so that
|
||||
* report always loads back.
|
||||
*/
|
||||
export class EnvironmentStateStore {
|
||||
#entities: ReadonlyMap<string, StateEntity> = new Map();
|
||||
@@ -37,6 +52,11 @@ export class EnvironmentStateStore {
|
||||
#content: StateContent = { entities: [], documents: [] };
|
||||
readonly #types: ReadonlyMap<string, StateType>;
|
||||
readonly #clockStart: number;
|
||||
/** What `data/get` writes for the current state, in UTF-8 bytes; kept by each mutation rather than reserialized. */
|
||||
#bytes = 0;
|
||||
/** The bytes of `data/get` with no entities or documents. */
|
||||
readonly #frameBytes: number;
|
||||
readonly #initialBytes: number;
|
||||
/** Served writes since the state was last reset or loaded: the clock's seconds and the generators' counter. */
|
||||
#writes = 0;
|
||||
/** The largest value each sequence has generated since the last reset or load, so a deleted entity's value is not reused. */
|
||||
@@ -48,12 +68,14 @@ export class EnvironmentStateStore {
|
||||
);
|
||||
this.#clockStart =
|
||||
state.writeClock === null ? 0 : Date.parse(state.writeClock);
|
||||
this.#frameBytes = jsonBytes(this.#snapshotOf([], []));
|
||||
this.#initialBytes = this.#contentBytes(state);
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/** Restore the initial state the Evidence holds; the clock and counter start again. */
|
||||
reset(): void {
|
||||
this.#install(this.state);
|
||||
this.#install(this.state, this.#initialBytes);
|
||||
this.#restart();
|
||||
}
|
||||
|
||||
@@ -61,7 +83,7 @@ export class EnvironmentStateStore {
|
||||
replace(document: unknown): Result<void, AnalysisInputError> {
|
||||
const content = parseStateDocument(this.state, document);
|
||||
if (!content.ok) return content;
|
||||
this.#install(content.value);
|
||||
this.#install(content.value, this.#contentBytes(content.value));
|
||||
this.#restart();
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -94,9 +116,16 @@ export class EnvironmentStateStore {
|
||||
return this.#content.entities.filter((entity) => entity.type === type);
|
||||
}
|
||||
|
||||
/** Add an entity at `index` of its collection's order. */
|
||||
insert(entity: StateEntity, index: number): void {
|
||||
/** Add an entity at `index` of its collection's order, unless the state would outgrow what `data/add` loads. */
|
||||
insert(
|
||||
entity: StateEntity,
|
||||
index: number,
|
||||
): Result<void, AnalysisResourceConstraintError> {
|
||||
const entities = this.#content.entities;
|
||||
const bytes = this.#grown(
|
||||
jsonBytes(entity) + (entities.length > 0 ? 1 : 0),
|
||||
);
|
||||
if (!bytes.ok) return bytes;
|
||||
const members = this.inCollection(entity.type, entity.collection);
|
||||
const anchor =
|
||||
index < members.length
|
||||
@@ -104,49 +133,92 @@ export class EnvironmentStateStore {
|
||||
: members.length > 0
|
||||
? entities.indexOf(members.at(-1)!) + 1
|
||||
: entities.length;
|
||||
this.#install({
|
||||
...this.#content,
|
||||
entities: [
|
||||
...entities.slice(0, anchor),
|
||||
entity,
|
||||
...entities.slice(anchor),
|
||||
],
|
||||
});
|
||||
this.#install(
|
||||
{
|
||||
...this.#content,
|
||||
entities: [
|
||||
...entities.slice(0, anchor),
|
||||
entity,
|
||||
...entities.slice(anchor),
|
||||
],
|
||||
},
|
||||
bytes.value,
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** Replace a held entity's values in place. */
|
||||
update(entity: StateEntity): void {
|
||||
/** Replace a held entity's values in place, unless the state would outgrow what `data/add` loads. */
|
||||
update(entity: StateEntity): Result<void, AnalysisResourceConstraintError> {
|
||||
const key = compositeKey([entity.type, entity.collection, entity.id]);
|
||||
this.#install({
|
||||
...this.#content,
|
||||
entities: this.#content.entities.map((held) =>
|
||||
compositeKey([held.type, held.collection, held.id]) === key
|
||||
? entity
|
||||
: held,
|
||||
),
|
||||
});
|
||||
const held = this.#entities.get(key);
|
||||
if (held === undefined) return ok(undefined);
|
||||
const bytes = this.#grown(jsonBytes(entity) - jsonBytes(held));
|
||||
if (!bytes.ok) return bytes;
|
||||
this.#install(
|
||||
{
|
||||
...this.#content,
|
||||
entities: this.#content.entities.map((candidate) =>
|
||||
candidate === held ? entity : candidate,
|
||||
),
|
||||
},
|
||||
bytes.value,
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
remove(entity: StateEntity): void {
|
||||
const key = compositeKey([entity.type, entity.collection, entity.id]);
|
||||
this.#install({
|
||||
...this.#content,
|
||||
entities: this.#content.entities.filter(
|
||||
(held) => compositeKey([held.type, held.collection, held.id]) !== key,
|
||||
),
|
||||
});
|
||||
const held = this.#entities.get(
|
||||
compositeKey([entity.type, entity.collection, entity.id]),
|
||||
);
|
||||
if (held === undefined) return;
|
||||
const entities = this.#content.entities.filter(
|
||||
(candidate) => candidate !== held,
|
||||
);
|
||||
this.#install(
|
||||
{ ...this.#content, entities },
|
||||
this.#bytes - jsonBytes(held) - (entities.length > 0 ? 1 : 0),
|
||||
);
|
||||
}
|
||||
|
||||
/** Set a held document's value; a value that is not the Evidence's cites no record. */
|
||||
setDocument(tool: string, value: JsonValue): void {
|
||||
this.#install({
|
||||
...this.#content,
|
||||
documents: this.#content.documents.map((document) =>
|
||||
document.tool === tool
|
||||
? { tool: document.tool, path: document.path, value }
|
||||
: document,
|
||||
/** Set a held document's value, unless the state would outgrow what `data/add` loads; a value that is not the Evidence's cites no record. */
|
||||
setDocument(
|
||||
tool: string,
|
||||
value: JsonValue,
|
||||
): Result<void, AnalysisResourceConstraintError> {
|
||||
const held = this.#documents.get(tool);
|
||||
if (held === undefined) return ok(undefined);
|
||||
const written = { tool: held.tool, path: held.path, value };
|
||||
const bytes = this.#grown(jsonBytes(written) - jsonBytes(held));
|
||||
if (!bytes.ok) return bytes;
|
||||
this.#install(
|
||||
{
|
||||
...this.#content,
|
||||
documents: this.#content.documents.map((document) =>
|
||||
document === held ? written : document,
|
||||
),
|
||||
},
|
||||
bytes.value,
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** The state's bytes after a change of `delta`, or a refusal when a change that grows it would pass the intake limit. */
|
||||
#grown(delta: number): Result<number, AnalysisResourceConstraintError> {
|
||||
const bytes = this.#bytes + delta;
|
||||
if (delta <= 0 || bytes <= STATE_INTAKE_LIMIT_BYTES) return ok(bytes);
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
ENVIRONMENT_SERVE_OPERATION,
|
||||
"capacity",
|
||||
"The write would take the served state past the size data/add loads",
|
||||
{ max_state_bytes: STATE_INTAKE_LIMIT_BYTES, state_bytes: bytes },
|
||||
{
|
||||
retryable: false,
|
||||
remediationAction:
|
||||
"Delete entities, or restore a smaller state with data/reset or data/add.",
|
||||
},
|
||||
),
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
type(collectionTemplate: string): StateType | undefined {
|
||||
@@ -176,19 +248,40 @@ export class EnvironmentStateStore {
|
||||
}
|
||||
|
||||
snapshot(): StateSnapshot {
|
||||
return this.#snapshotOf(this.#content.entities, this.#content.documents);
|
||||
}
|
||||
|
||||
/** What `data/get` writes for the current state, in UTF-8 bytes. */
|
||||
byteLength(): number {
|
||||
return this.#bytes;
|
||||
}
|
||||
|
||||
#snapshotOf(
|
||||
entities: readonly StateEntity[],
|
||||
documents: readonly HeldDocument[],
|
||||
): StateSnapshot {
|
||||
return {
|
||||
rea_state: {
|
||||
version: 1,
|
||||
state_evidence_id: this.state.evidenceId,
|
||||
types: this.state.types,
|
||||
entities: this.#content.entities,
|
||||
documents: this.#content.documents,
|
||||
entities,
|
||||
documents,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What `data/get` writes for some content, summed item by item so the whole state is never one string. */
|
||||
#contentBytes(content: StateContent): number {
|
||||
return (
|
||||
this.#frameBytes +
|
||||
itemsBytes(content.entities) +
|
||||
itemsBytes(content.documents)
|
||||
);
|
||||
}
|
||||
|
||||
/** Build every index before assigning any, so a reader never sees half a state. */
|
||||
#install(content: StateContent): void {
|
||||
#install(content: StateContent, bytes: number): void {
|
||||
const entities = new Map<string, StateEntity>();
|
||||
const collections = new Map<string, StateEntity[]>();
|
||||
const typeCollections = new Map<string, string[]>();
|
||||
@@ -216,5 +309,6 @@ export class EnvironmentStateStore {
|
||||
content.documents.map((document) => [document.tool, document]),
|
||||
);
|
||||
this.#content = content;
|
||||
this.#bytes = bytes;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
timedCaptureEvidence,
|
||||
type TimedCaptureRow,
|
||||
} from "../../../tests/fixtures/analysisView.js";
|
||||
import {
|
||||
environmentStateServeFixture,
|
||||
environmentStateServeInputs,
|
||||
} from "../../../tests/fixtures/environmentServe.js";
|
||||
servedFrom,
|
||||
type Served,
|
||||
} from "../../../tests/fixtures/environmentStateServing.js";
|
||||
import {
|
||||
BOOK_INIT_ROWS,
|
||||
BOOK_NOT_FOUND,
|
||||
@@ -21,52 +17,14 @@ import {
|
||||
renamedBook,
|
||||
type Book,
|
||||
} from "../../../tests/fixtures/environmentStateWrites.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import type { ReplayArguments } from "./environmentReplay.js";
|
||||
import { servedCatalog } from "./environmentState.js";
|
||||
import { EnvironmentStateStore } from "./environmentStateStore.js";
|
||||
import { environmentToolAnswer } from "./environmentToolAnswer.js";
|
||||
import { environmentToolDefinitions } from "./environmentTools.js";
|
||||
|
||||
/** A served environment over the state derived from a capture of `rows`, answering tool calls as the server does. */
|
||||
const servedFrom = (rows: readonly TimedCaptureRow[] = BOOK_ROWS) => {
|
||||
const { source, state } = environmentStateServeInputs(
|
||||
environmentStateServeFixture(timedCaptureEvidence(rows)),
|
||||
);
|
||||
const store = new EnvironmentStateStore(state);
|
||||
const respond = environmentToolAnswer(source, store);
|
||||
const call = (name: string, args: ReplayArguments) => {
|
||||
const tool = source.manifest.tools.find(
|
||||
(candidate) => candidate.name === name,
|
||||
);
|
||||
if (tool === undefined) throw new Error(`The manifest lacks ${name}`);
|
||||
return respond(tool, args);
|
||||
};
|
||||
return {
|
||||
source,
|
||||
state,
|
||||
store,
|
||||
answer: (name: string, args: ReplayArguments = {}) => {
|
||||
const result = call(name, args);
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
},
|
||||
refusal: (name: string, args: ReplayArguments = {}) => {
|
||||
const result = call(name, args);
|
||||
if (result.ok) throw new Error(`Expected ${name} to be refused`);
|
||||
return projectAnalysisError(result.error);
|
||||
},
|
||||
/** What data/get reports, as text, so whole states compare byte for byte. */
|
||||
snapshot: () => JSON.stringify(store.snapshot()),
|
||||
};
|
||||
};
|
||||
|
||||
/** The book the first served create of "New Book" makes: the next id, one second past the served clock's start. */
|
||||
const NEW_BOOK = book(3, "New Book", bookClock(13));
|
||||
const CREATE_NEW_BOOK = { body: { name: "New Book" } };
|
||||
|
||||
type Served = ReturnType<typeof servedFrom>;
|
||||
|
||||
const entityOf = (books: Served, id: string) =>
|
||||
books.store.snapshot().rea_state.entities.find((entity) => entity.id === id);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
bodyProblem,
|
||||
createdFields,
|
||||
updatedEntity,
|
||||
updatedFields,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
spelledLike,
|
||||
} from "../../domain/analysisView/environmentStateWriteValues.js";
|
||||
import {
|
||||
at,
|
||||
isObject,
|
||||
scalarText,
|
||||
type JsonObject,
|
||||
@@ -49,6 +51,11 @@ import {
|
||||
type StateProvenance,
|
||||
type StateServing,
|
||||
} from "./environmentStateAnswer.js";
|
||||
import {
|
||||
isLoadableValue,
|
||||
unloadableMember,
|
||||
type HeldDocument,
|
||||
} from "./environmentStateDocument.js";
|
||||
import { environmentToolArguments } from "./environmentTools.js";
|
||||
|
||||
/** Attempts at a generated UUID or token before a collision with held values is refused. */
|
||||
@@ -79,7 +86,17 @@ const pathValues = (
|
||||
return values;
|
||||
};
|
||||
|
||||
/** The call's body: an object, nothing, or a refusal for any other value. */
|
||||
/** A refusal for a value `data/add` would refuse, so `data/get` always reports a state that loads back. */
|
||||
const unloadable = (
|
||||
tool: EnvironmentManifestTool,
|
||||
holder: string,
|
||||
): AnalysisError =>
|
||||
refused(
|
||||
tool,
|
||||
`${holder} would hold a value that is not exactly representable JSON, which data/add could not load back.`,
|
||||
);
|
||||
|
||||
/** The call's body: an object, nothing, or a refusal for any other value or one holding a value the state cannot hold. */
|
||||
const callBody = ({
|
||||
tool,
|
||||
args,
|
||||
@@ -87,9 +104,12 @@ const callBody = ({
|
||||
const name = environmentToolArguments(tool).body;
|
||||
const value = name === undefined ? undefined : args[name];
|
||||
if (value === undefined) return ok(undefined);
|
||||
return isObject(value as JsonValue)
|
||||
if (!isObject(value as JsonValue))
|
||||
return err(refused(tool, `Tool ${tool.name} takes a JSON object body.`));
|
||||
const member = unloadableMember(value as JsonObject);
|
||||
return member === undefined
|
||||
? ok(value as JsonObject)
|
||||
: err(refused(tool, `Tool ${tool.name} takes a JSON object body.`));
|
||||
: err(unloadable(tool, `Member ${member}`));
|
||||
};
|
||||
|
||||
/** The entity a recorded observation shows: the values a create keeps and an update's constants. */
|
||||
@@ -257,6 +277,8 @@ const answerCreate = (
|
||||
});
|
||||
const fields = createdFields(plan, observed, call, values.generate);
|
||||
if (!fields.ok) return err(refused(tool, fields.error));
|
||||
const field = unloadableMember(fields.value);
|
||||
if (field !== undefined) return err(unloadable(tool, `Field ${field}`));
|
||||
const id = scalarText(fields.value[idField]);
|
||||
if (
|
||||
id === undefined ||
|
||||
@@ -282,7 +304,7 @@ const answerCreate = (
|
||||
state,
|
||||
);
|
||||
if (!answer.ok) return answer;
|
||||
serving.store.insert(
|
||||
const inserted = serving.store.insert(
|
||||
{
|
||||
type,
|
||||
collection,
|
||||
@@ -297,6 +319,7 @@ const answerCreate = (
|
||||
fields.value,
|
||||
),
|
||||
);
|
||||
if (!inserted.ok) return inserted;
|
||||
serving.store.commitWrite(values.taken);
|
||||
return answer;
|
||||
};
|
||||
@@ -346,6 +369,9 @@ const answerUpdate = (
|
||||
values.generate,
|
||||
);
|
||||
if (!written.ok) return err(refused(tool, written.error));
|
||||
// An increment can pass the exact integers.
|
||||
const field = unloadableMember(written.value);
|
||||
if (field !== undefined) return err(unloadable(tool, `Field ${field}`));
|
||||
const answer = writeAnswer(
|
||||
serving,
|
||||
request,
|
||||
@@ -358,10 +384,10 @@ const answerUpdate = (
|
||||
id: entity.id,
|
||||
},
|
||||
);
|
||||
if (answer.ok) {
|
||||
serving.store.update(updatedEntity(entity, written.value));
|
||||
serving.store.commitWrite(values.taken);
|
||||
}
|
||||
if (!answer.ok) return answer;
|
||||
const updated = serving.store.update(updatedEntity(entity, written.value));
|
||||
if (!updated.ok) return updated;
|
||||
serving.store.commitWrite(values.taken);
|
||||
return answer;
|
||||
};
|
||||
|
||||
@@ -391,19 +417,56 @@ const answerDelete = (
|
||||
return answer;
|
||||
};
|
||||
|
||||
/** The held document a call changes: only at the path the state holds it, with members the capture sent as the JSON types the document holds. */
|
||||
const documentTarget = (
|
||||
serving: StateServing,
|
||||
{ tool, plan, args }: WriteRequest,
|
||||
call: WriteCall,
|
||||
): Result<HeldDocument, AnalysisError> => {
|
||||
const held = serving.store.document(plan.target ?? "");
|
||||
const segments = callSegments(tool, args);
|
||||
if (held === undefined || plan.body === null)
|
||||
return err(
|
||||
refused(tool, `The state holds no document for ${plan.target ?? ""}.`),
|
||||
);
|
||||
if (segments === undefined)
|
||||
return err(refused(tool, `Tool ${tool.name} needs every path argument.`));
|
||||
// Document reads answer only on their recorded line, so a write elsewhere has no document to change.
|
||||
if (collectionMatch(held.path, segments) === undefined)
|
||||
return err(
|
||||
refused(
|
||||
tool,
|
||||
`The state holds this document only at ${held.path}; the environment models writes to it only there.`,
|
||||
),
|
||||
);
|
||||
const pointer = plan.observation?.pointer ?? "";
|
||||
const members = (value: JsonValue | undefined): JsonObject => {
|
||||
const object = value === undefined ? undefined : at(value, pointer);
|
||||
return isObject(object) ? object : {};
|
||||
};
|
||||
const initial = serving.store.state.documents.find(
|
||||
(document) => document.tool === held.tool,
|
||||
);
|
||||
// A member a replacing write dropped keeps the type the capture showed.
|
||||
const problem = bodyProblem(plan.body, call, {
|
||||
entity: { ...members(initial?.value), ...members(held.value) },
|
||||
});
|
||||
return problem === undefined ? ok(held) : err(refused(tool, problem));
|
||||
};
|
||||
|
||||
const answerDocument = (
|
||||
serving: StateServing,
|
||||
request: WriteRequest,
|
||||
call: WriteCall,
|
||||
): Result<StateAnswer, AnalysisError> => {
|
||||
const { tool, plan } = request;
|
||||
const held = serving.store.document(plan.target ?? "");
|
||||
if (held === undefined)
|
||||
return err(
|
||||
refused(tool, `The state holds no document for ${plan.target ?? ""}.`),
|
||||
);
|
||||
const target = documentTarget(serving, request, call);
|
||||
if (!target.ok) return target;
|
||||
const held = target.value;
|
||||
const written = writtenDocument(plan, held.value, call);
|
||||
if (!written.ok) return err(refused(tool, written.error));
|
||||
if (!isLoadableValue(written.value))
|
||||
return err(unloadable(tool, "The document"));
|
||||
const state = {
|
||||
source: "state",
|
||||
view: "document-write",
|
||||
@@ -423,10 +486,10 @@ const answerDocument = (
|
||||
);
|
||||
})()
|
||||
: writeAnswer(serving, request, { fields: undefined, id: "" }, state);
|
||||
if (answer.ok) {
|
||||
serving.store.setDocument(plan.target ?? "", written.value);
|
||||
serving.store.commitWrite(new Map());
|
||||
}
|
||||
if (!answer.ok) return answer;
|
||||
const set = serving.store.setDocument(held.tool, written.value);
|
||||
if (!set.ok) return set;
|
||||
serving.store.commitWrite(new Map());
|
||||
return answer;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { TimedCaptureRow } from "../../../tests/fixtures/analysisView.js";
|
||||
import {
|
||||
servedFrom,
|
||||
type Served,
|
||||
} from "../../../tests/fixtures/environmentStateServing.js";
|
||||
import {
|
||||
FIRST_BOOK,
|
||||
SECOND_BOOK,
|
||||
renamedBook,
|
||||
type Book,
|
||||
} from "../../../tests/fixtures/environmentStateWrites.js";
|
||||
import { MAX_JSON_DEPTH, type JsonValue } from "../../domain/jsonValue.js";
|
||||
import type { ReplayArguments } from "./environmentReplay.js";
|
||||
import { STATE_INTAKE_LIMIT_BYTES } from "./environmentStateDocument.js";
|
||||
|
||||
const DARK = { theme: "dark", meta: { n: 1 } };
|
||||
const LIGHT = { theme: "light", meta: { n: 2 } };
|
||||
|
||||
/** One organisation's settings document, read, patched with both members and read again. */
|
||||
const SETTINGS_ROWS: readonly TimedCaptureRow[] = [
|
||||
["GET", "/api/orgs/1/settings", DARK],
|
||||
["PATCH", "/api/orgs/1/settings", LIGHT, 200, LIGHT],
|
||||
["GET", "/api/orgs/1/settings", LIGHT],
|
||||
];
|
||||
|
||||
const READ_SETTINGS = "get_orgs_settings_by_id";
|
||||
const PATCH_SETTINGS = "patch_orgs_settings_by_id";
|
||||
|
||||
/** Every refused call is a tool error naming its problem, and leaves the state as it was. */
|
||||
const expectRefusedUnchanged = (
|
||||
served: Served,
|
||||
refused: readonly (readonly [string, ReplayArguments, string])[],
|
||||
): void => {
|
||||
const before = served.snapshot();
|
||||
for (const [name, args, message] of refused) {
|
||||
expect(served.refusal(name, args)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
message: expect.stringContaining(message),
|
||||
});
|
||||
expect(served.snapshot()).toBe(before);
|
||||
}
|
||||
};
|
||||
|
||||
/** What data/get reports loads back through data/add byte for byte. */
|
||||
const expectReloadsUnchanged = (served: Served): void => {
|
||||
const before = served.snapshot();
|
||||
expect(served.store.replace(JSON.parse(before)).ok).toBe(true);
|
||||
expect(served.snapshot()).toBe(before);
|
||||
};
|
||||
|
||||
const nested = (depth: number): JsonValue =>
|
||||
Array.from({ length: depth }).reduce<JsonValue>((inner) => [inner], 1);
|
||||
|
||||
describe("served document writes", () => {
|
||||
it("accepts only the members the capture sent, as the JSON types the document holds", () => {
|
||||
const settings = servedFrom(SETTINGS_ROWS);
|
||||
expect(settings.state.writes.get(PATCH_SETTINGS)).toMatchObject({
|
||||
served: true,
|
||||
effect: "document",
|
||||
});
|
||||
expectRefusedUnchanged(settings, [
|
||||
[
|
||||
PATCH_SETTINGS,
|
||||
{ id: "1", body: { theme: 5, extra: true } },
|
||||
"Member theme must be a JSON string",
|
||||
],
|
||||
[
|
||||
PATCH_SETTINGS,
|
||||
{ id: "1", body: { extra: true } },
|
||||
"Member extra is not one the capture showed stored",
|
||||
],
|
||||
]);
|
||||
const blue = { theme: "blue", meta: DARK.meta };
|
||||
expect(
|
||||
settings.answer(PATCH_SETTINGS, { id: "1", body: { theme: "blue" } }),
|
||||
).toMatchObject({
|
||||
body: blue,
|
||||
state: { view: "document-write", path: "/api/orgs/1/settings" },
|
||||
});
|
||||
expect(settings.answer(READ_SETTINGS, { id: "1" }).body).toEqual(blue);
|
||||
});
|
||||
|
||||
it("changes the document only at the path the state holds it", () => {
|
||||
const settings = servedFrom(SETTINGS_ROWS);
|
||||
expectRefusedUnchanged(settings, [
|
||||
[
|
||||
PATCH_SETTINGS,
|
||||
{ id: "2", body: { theme: "blue" } },
|
||||
"holds this document only at /api/orgs/1/settings",
|
||||
],
|
||||
]);
|
||||
expect(settings.answer(READ_SETTINGS, { id: "1" }).body).toEqual(DARK);
|
||||
});
|
||||
});
|
||||
|
||||
describe("values the state can load back", () => {
|
||||
it("refuses a body value data/add would refuse, whatever member carries it", () => {
|
||||
const settings = servedFrom(SETTINGS_ROWS);
|
||||
const unexact =
|
||||
"Member meta would hold a value that is not exactly representable JSON";
|
||||
expectRefusedUnchanged(settings, [
|
||||
[PATCH_SETTINGS, { id: "1", body: { meta: { n: 1e20 } } }, unexact],
|
||||
[
|
||||
PATCH_SETTINGS,
|
||||
{
|
||||
id: "1",
|
||||
body: JSON.parse('{"meta":{"__proto__":{"n":1}}}') as JsonValue,
|
||||
},
|
||||
unexact,
|
||||
],
|
||||
[
|
||||
PATCH_SETTINGS,
|
||||
{ id: "1", body: { meta: { n: nested(MAX_JSON_DEPTH) } } },
|
||||
unexact,
|
||||
],
|
||||
]);
|
||||
settings.answer(PATCH_SETTINGS, { id: "1", body: { meta: { n: 3 } } });
|
||||
expectReloadsUnchanged(settings);
|
||||
});
|
||||
|
||||
it("refuses an increment past the exact integers, so data/get still loads back", () => {
|
||||
const versioned = (entry: Book, version: number): Book => ({
|
||||
...entry,
|
||||
version,
|
||||
});
|
||||
const last = Number.MAX_SAFE_INTEGER;
|
||||
const edition = versioned(renamedBook(SECOND_BOOK, "Second Edition", 3), 2);
|
||||
const books = servedFrom([
|
||||
[
|
||||
"GET",
|
||||
"/api/books",
|
||||
[versioned(FIRST_BOOK, last - 1), versioned(SECOND_BOOK, 1)],
|
||||
],
|
||||
["GET", "/api/books/1", versioned(FIRST_BOOK, last - 1)],
|
||||
["GET", "/api/books/2", versioned(SECOND_BOOK, 1)],
|
||||
["PATCH", "/api/books/2", edition, 200, { name: "Second Edition" }],
|
||||
["GET", "/api/books/2", edition],
|
||||
]);
|
||||
const rename = (name: string) => ({ id: "1", body: { name } });
|
||||
expect(
|
||||
books.answer("patch_books_by_id", rename("Renamed")).body,
|
||||
).toMatchObject({ version: last });
|
||||
expectRefusedUnchanged(books, [
|
||||
[
|
||||
"patch_books_by_id",
|
||||
rename("Renamed Again"),
|
||||
"Field version would hold a value that is not exactly representable JSON",
|
||||
],
|
||||
]);
|
||||
expectReloadsUnchanged(books);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the state size limit", () => {
|
||||
it("keeps the size data/get writes as each write changes the state", () => {
|
||||
const books = servedFrom();
|
||||
const settings = servedFrom(SETTINGS_ROWS);
|
||||
const tracked = (served: Served) =>
|
||||
expect(served.store.byteLength()).toBe(
|
||||
Buffer.byteLength(served.snapshot(), "utf8"),
|
||||
);
|
||||
tracked(books);
|
||||
books.answer("post_books", { body: { name: "Ünïcode Bøok" } });
|
||||
tracked(books);
|
||||
books.answer("patch_books_by_id", { id: "3", body: { name: "Short" } });
|
||||
tracked(books);
|
||||
books.answer("delete_books_by_id", { id: "1" });
|
||||
books.answer("delete_books_by_id", { id: "2" });
|
||||
books.answer("delete_books_by_id", { id: "3" });
|
||||
tracked(books);
|
||||
books.answer("post_books", { body: { name: "Only Book" } });
|
||||
tracked(books);
|
||||
settings.answer(PATCH_SETTINGS, { id: "1", body: { theme: "dark blue" } });
|
||||
tracked(settings);
|
||||
expect(settings.store.replace(JSON.parse(settings.snapshot())).ok).toBe(
|
||||
true,
|
||||
);
|
||||
tracked(settings);
|
||||
});
|
||||
|
||||
it("refuses a create that would take data/get past what data/add loads", () => {
|
||||
const books = servedFrom();
|
||||
// Each answer renders the book, so each stays under the reply body limit.
|
||||
const create = { body: { name: "a".repeat(100_000) } };
|
||||
let refused: ReturnType<Served["call"]> | undefined;
|
||||
for (let made = 0; made < 200 && refused === undefined; made += 1) {
|
||||
const result = books.call("post_books", create);
|
||||
if (!result.ok) refused = result;
|
||||
}
|
||||
const before = books.snapshot();
|
||||
expect(Buffer.byteLength(before, "utf8")).toBeLessThanOrEqual(
|
||||
STATE_INTAKE_LIMIT_BYTES,
|
||||
);
|
||||
expect(refused?.ok === false && refused.error).toMatchObject({
|
||||
resource: "capacity",
|
||||
reportedLimits: { max_state_bytes: STATE_INTAKE_LIMIT_BYTES },
|
||||
});
|
||||
expect(books.refusal("post_books", create)).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
});
|
||||
expect(books.snapshot()).toBe(before);
|
||||
expectReloadsUnchanged(books);
|
||||
// A smaller state has room again.
|
||||
books.answer("delete_books_by_id", { id: "3" });
|
||||
expect(books.answer("post_books", create).status).toBe(201);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { STATE_INTAKE_LIMIT_BYTES } from "../../application/environment/environmentStateDocument.js";
|
||||
import { mediaTypeEssence } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import { isExactJsonNumber } from "../../domain/exactJsonNumber.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
@@ -44,8 +45,7 @@ export const CANCELLED: RpcError = {
|
||||
data: { reason: "cancelled" },
|
||||
};
|
||||
|
||||
/** A loaded state document is at most this large, whether it arrives as bytes, a file or a download. */
|
||||
export const STATE_INTAKE_LIMIT_BYTES = 32 * 1024 * 1024;
|
||||
export { STATE_INTAKE_LIMIT_BYTES };
|
||||
/** An https state download must finish within this time. */
|
||||
export const STATE_FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { ReplayArguments } from "../../src/application/environment/environmentReplay.js";
|
||||
import { EnvironmentStateStore } from "../../src/application/environment/environmentStateStore.js";
|
||||
import { environmentToolAnswer } from "../../src/application/environment/environmentToolAnswer.js";
|
||||
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
|
||||
import { timedCaptureEvidence, type TimedCaptureRow } from "./analysisView.js";
|
||||
import {
|
||||
environmentStateServeFixture,
|
||||
environmentStateServeInputs,
|
||||
} from "./environmentServe.js";
|
||||
import { BOOK_ROWS } from "./environmentStateWrites.js";
|
||||
|
||||
/** A served environment over the state derived from a capture of `rows`, answering tool calls as the server does. */
|
||||
export const servedFrom = (rows: readonly TimedCaptureRow[] = BOOK_ROWS) => {
|
||||
const { source, state } = environmentStateServeInputs(
|
||||
environmentStateServeFixture(timedCaptureEvidence(rows)),
|
||||
);
|
||||
const store = new EnvironmentStateStore(state);
|
||||
const respond = environmentToolAnswer(source, store);
|
||||
const call = (name: string, args: ReplayArguments) => {
|
||||
const tool = source.manifest.tools.find(
|
||||
(candidate) => candidate.name === name,
|
||||
);
|
||||
if (tool === undefined) throw new Error(`The manifest lacks ${name}`);
|
||||
return respond(tool, args);
|
||||
};
|
||||
return {
|
||||
source,
|
||||
state,
|
||||
store,
|
||||
call,
|
||||
answer: (name: string, args: ReplayArguments = {}) => {
|
||||
const result = call(name, args);
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
},
|
||||
refusal: (name: string, args: ReplayArguments = {}) => {
|
||||
const result = call(name, args);
|
||||
if (result.ok) throw new Error(`Expected ${name} to be refused`);
|
||||
return projectAnalysisError(result.error);
|
||||
},
|
||||
/** What data/get reports, as text, so whole states compare byte for byte. */
|
||||
snapshot: () => JSON.stringify(store.snapshot()),
|
||||
};
|
||||
};
|
||||
|
||||
export type Served = ReturnType<typeof servedFrom>;
|
||||
Reference in new issue
Block a user