Skip to content

Commit a2f298c

Browse files
authored
✨ Slice A of #848: project REPL views from durable history (#849)
* ✨ Project REPL views from durable history Slice A of #848: the durable reconstruction kernel. Code outside the runtime can take an array of the repository's real `Yield | Close` events and obtain a deeply frozen view of one execution, for the whole Journal or for one inclusive checkpoint prefix, and can address any part of that view with a URL. There is no REPL yet — no storage, no session, no component tree, no command — and nothing in core's execution semantics changed. `<Elicit>` now retains the compiled schema beside its answer. The validated answer is still the record's result and the fingerprint over schema and message is still the only guard; the schema travels in the same description under `executablemd.elicitation-schema`, where a source position already travels, so `type` and `name` still decide what a replay matches. Historical inspection needs to know which fields the person was shown, and the provider that could have said so is not running any more. `@executablemd/core/host` publishes the field name and a parsing reader for it, because a description is journal data and a schema that merely looked plausible would reach a form as one. `packages/cli/src/repl/model.ts` projects the vocabulary an ordinary execution already appends — root and nested `import_component`, `eval`, `generated_xmd`, `elicit`, and the root `Close` — into immutable structural values, deep-frozen, with no REPL record, no cache, and no `DurableEvent` leaving the module. Every retained value is detached from the event that carried it — copied, then frozen — so the model cannot be changed by whoever still holds the events, and the events are neither frozen nor modified by having been read: a projector that froze its input would make a caller's own data immutable as a side effect of being looked at. A checkpoint marker is derived from protocol identity, `yield:<coroutine>:<ordinal>` or `close:<coroutine>`, so the same event has the same marker in every process that reads the file and nothing extra is written down to make that true. Attribution is by the source path a position names: an effect whose owner is absent, ambiguous or unreadable refuses, because a binding attached to a guessed scope is a value shown where nothing published it. `packages/cli/src/repl/route.ts` is the pure half of navigation. `decodeLocation` decides only what a grammar can decide, `encodeLocation` emits one canonical spelling, and `resolveLocation` answers with the exact objects the projection holds. The two are separate so that a typo in a location is never answered with a guess about the history. Evidence runs against a journal produced by really executing the reference entry rather than a recorded array, so the projector is held to the vocabulary the current runtime writes. The fixture holds one of each thing this slice projects: a durable evaluation publishing JSON, one nested component occurrence whose source the run retains, one generated fragment admitted from a value that evaluation published, and one validated question whose answer changes what renders after it. One thing the vocabulary turned out to say that a reader has to handle: the root `Close` records three outcomes, not one — a document result, a protocol `err` for a run that failed before producing one, and `cancelled` — so `ReplTerminal` has three statuses and only the first carries rendered output. A serialized `err` also carries a stack naming host paths, so the transcript shows its message and nothing else. * ⚡ Remeasure test weights at 970bc0d Slice A adds `repl-model.test.ts` and `repl-route.test.ts`, so the corpus moved and every runtime's partition was reading a fallback weight for both. Measured by **Measure test weights** run 36270414251 against `970bc0db`, on `ubuntu-latest`, and committed exactly as the artifact came out. The shard counts do not move. The measured floors are 14, 8 and 4 for Deno, Node and Bun; the installed counts are 15, 10 and 5, so each is already above its floor and no five-run evidence asks for a change.
1 parent e9855a0 commit a2f298c

13 files changed

Lines changed: 3583 additions & 1071 deletions

File tree

‎packages/cli/src/repl/model.ts‎

Lines changed: 865 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/cli/src/repl/route.ts‎

Lines changed: 467 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
title: Remaining work on one plan
3+
required: [title, steps]
4+
props:
5+
title:
6+
type: string
7+
steps:
8+
type: number
9+
---
10+
11+
### {props.title}
12+
13+
{props.steps} steps remain.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
```js eval
2+
const plan = { title: "Ship the REPL", steps: 2 };
3+
const summarySource = `<Json value={${JSON.stringify(plan)}} />`;
4+
const responseSchema = {
5+
type: "object",
6+
properties: { decision: { type: "string", enum: ["approve", "decline"] } },
7+
required: ["decision"],
8+
additionalProperties: false,
9+
};
10+
```
11+
12+
<Checklist title={plan.title} steps={plan.steps} />
13+
14+
<Evaluate text={summarySource} />
15+
16+
<Elicit schema={responseSchema} as="response">Approve {plan.title}?</Elicit>
17+
18+
Decision: {response.decision}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* The reference entry, executed for real.
3+
*
4+
* Every REPL test that needs a Journal gets one from here rather than from a
5+
* recorded file, because the projector's whole claim is that it reads what the
6+
* *current* runtime writes. A frozen event array would keep passing on the day
7+
* core changed a record's shape, which is the day the claim stopped being true.
8+
*
9+
* The entry is one document deliberately holding one of each thing this slice
10+
* projects: a durable evaluation that publishes JSON, one nested component
11+
* occurrence whose source the run retains, one generated fragment admitted from
12+
* a value that evaluation published, and one validated question whose answer
13+
* changes what the document renders after it.
14+
*/
15+
16+
import { readTextFile } from "@effectionx/fs";
17+
import { fileURLToPath } from "node:url";
18+
import { InMemoryStream } from "@executablemd/durable-streams";
19+
import type { DurableEvent, Json } from "@executablemd/durable-streams";
20+
import { collect, Elicitation } from "@executablemd/core";
21+
import type { ElicitationRequest } from "@executablemd/core";
22+
import { executeInstalled } from "@executablemd/core/host";
23+
import { inlineSource } from "@executablemd/core";
24+
import type { Operation } from "effection";
25+
import { scoped } from "effection";
26+
27+
import { ordinaryEvaluationProfile } from "../../../src/evaluation-profile.ts";
28+
29+
/** Where the entry and its nested component live. */
30+
export const REFERENCE_DIRECTORY = fileURLToPath(new URL("./", import.meta.url));
31+
32+
/** The exact entry text this slice's evidence submits. */
33+
export function referenceSource(): Operation<string> {
34+
return readTextFile(fileURLToPath(new URL("./entry.md", import.meta.url)));
35+
}
36+
37+
/** What one reference execution produced. */
38+
export interface ReferenceRun {
39+
readonly output: string;
40+
readonly events: DurableEvent[];
41+
readonly asked: ElicitationRequest[];
42+
readonly failure: Error | undefined;
43+
}
44+
45+
/**
46+
* Run the reference entry once, over `stream`.
47+
*
48+
* `answer` decides what the installed provider hands back, so a test can answer,
49+
* refuse, or count. Failures are captured rather than raised: how far a run got
50+
* is most of what the negative controls are about.
51+
*/
52+
export function runReference(
53+
answer: (request: ElicitationRequest) => Operation<unknown>,
54+
stream: InMemoryStream = new InMemoryStream(),
55+
): Operation<ReferenceRun> {
56+
return scoped(function* () {
57+
const asked: ElicitationRequest[] = [];
58+
yield* Elicitation.around(
59+
{
60+
*elicit([request]) {
61+
asked.push(request);
62+
return yield* answer(request);
63+
},
64+
},
65+
{ at: "min" },
66+
);
67+
const source = yield* referenceSource();
68+
try {
69+
const execution = yield* executeInstalled(
70+
{ ...inlineSource(source), stream, includes: [REFERENCE_DIRECTORY] },
71+
[{ evaluation: ordinaryEvaluationProfile() }],
72+
);
73+
const output = yield* collect(execution);
74+
return { output: String(output), events: yield* stream.readAll(), asked, failure: undefined };
75+
} catch (error) {
76+
return {
77+
output: "",
78+
events: yield* stream.readAll(),
79+
asked,
80+
failure: error instanceof Error ? error : new Error(String(error)),
81+
};
82+
}
83+
});
84+
}
85+
86+
/** The answer the reference journey submits. */
87+
export const REFERENCE_ANSWER: Json = { decision: "approve" };
88+
89+
/** A provider that always answers the same way. */
90+
export function answering(value: Json): (request: ElicitationRequest) => Operation<unknown> {
91+
// deno-lint-ignore require-yield
92+
return function* () {
93+
return value;
94+
};
95+
}
96+
97+
/** The complete reference Journal of one settled run. */
98+
export function* referenceEvents(): Operation<DurableEvent[]> {
99+
const run = yield* runReference(answering(REFERENCE_ANSWER));
100+
if (run.failure !== undefined) {
101+
throw run.failure;
102+
}
103+
return run.events;
104+
}

0 commit comments

Comments
 (0)