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
22 changes: 22 additions & 0 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -6361,6 +6361,28 @@ projection detaches every value it retains and freezes it, so nothing below
holds a `DurableEvent` and nothing above can change one. Route resolution is
pure and answers with the exact objects that model holds.

### Source ownership in retained entries

A source position identifies authored bytes, not which invocation used them.
Repeated calls therefore retain `executablemd.source-owner` on the operations
the REPL attributes: the coroutine ID and zero-based yield index of the prior
successful source import. Canonical import admission captures this coordinate,
and Core binds the internal `SourceOwner` Context, named
`executablemd.source-owner`, around the source body. Branches and iterations
inherit the binding. A component body uses its own import; projected caller
content restores its captured caller binding. Attribution writers read it before
handing work to a separately owned task, so copying a public code-block context
preserves attribution. It grants no permission and creates no effect, coroutine
or identity allocator.

Projection resolves the exact import and checks its source, prior order and
coroutine ancestry. An invalid explicit reference refuses; it never falls back
to a path guess. Legacy histories use the existing unique-owner inference and
retain their ambiguity refusals. Continuation can reference a legacy import
because both live and replayed yields advance the existing durable position.
Neither reading nor continuation rewrites earlier records to add ownership.
Generated-fragment attribution keeps its existing admission identity.

### Sequential entries over one physical journal

One execution is one append-only stream, and entries are ranges of it. A segment
Expand Down
96 changes: 70 additions & 26 deletions packages/cli/src/repl/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const ENTRY_SCOPE = entryKey(1);
const ROOT_PROPS_BINDING = "props";

const SOURCE_POSITION_FIELD = "executablemd.source-position";
const SOURCE_OWNER_FIELD = "executablemd.source-owner";

/** What the REPL could not read, and what it was reading when it stopped. */
export class ReplProjectionError extends Error {
Expand Down Expand Up @@ -741,7 +742,16 @@ function buildEntry(segment: EntrySegment, markers: readonly string[]): Result<E
const turns: ReplAgentTurn[] = [];
let terminal: ReplTerminal | undefined;
/** Every admission by the source path it was admitted from, for owner lookup. */
const byPath = new Map<string, Admission[]>();
const sources = { paths: new Map<string, Admission[]>(), imports: new Map<string, Admission>() };
const yields = new Map<string, number>();
const coordinates = events.map((event) => {
if (event.type !== "yield") {
return undefined;
}
const index = yields.get(event.coroutineId) ?? 0;
yields.set(event.coroutineId, index + 1);
return `${event.coroutineId}:${index}`;
});
/** Every generated fragment this prefix admitted, by the identity it carries. */
const fragments: GeneratedOwner[] = [];
/**
Expand Down Expand Up @@ -780,7 +790,9 @@ function buildEntry(segment: EntrySegment, markers: readonly string[]): Result<E
generated: [],
scopes: [],
};
register(byPath, { ...site(segment.admission, 0), scope: entry });
const rootAdmission = { ...site(segment.admission, 0), scope: entry };
register(sources.paths, rootAdmission);
sources.imports.set(coordinates[0]!, rootAdmission);
transcript.push({
kind: "entry",
marker: markers[0],
Expand Down Expand Up @@ -861,7 +873,7 @@ function buildEntry(segment: EntrySegment, markers: readonly string[]): Result<E
continue;
}
const owner = ownerOf(
byPath,
sources,
fragments,
announced,
position,
Expand All @@ -888,7 +900,9 @@ function buildEntry(segment: EntrySegment, markers: readonly string[]): Result<E
scopes: [],
};
owner.value.scope.scopes.push(scope);
register(byPath, { ...site(event, index), scope });
const admission = { ...site(event, index), scope };
register(sources.paths, admission);
sources.imports.set(coordinates[index]!, admission);
transcript.push({
kind: "scope",
marker,
Expand All @@ -911,7 +925,7 @@ function buildEntry(segment: EntrySegment, markers: readonly string[]): Result<E
}
const published = read.value;
const owner = ownerOf(
byPath,
sources,
fragments,
announced,
position,
Expand Down Expand Up @@ -967,7 +981,7 @@ function buildEntry(segment: EntrySegment, markers: readonly string[]): Result<E
);
}
const owner = ownerOf(
byPath,
sources,
fragments,
announced,
position,
Expand Down Expand Up @@ -1047,7 +1061,7 @@ function buildEntry(segment: EntrySegment, markers: readonly string[]): Result<E
return Err(new ReplProjectionError("a recorded question records no answer at all."));
}
const owner = ownerOf(
byPath,
sources,
fragments,
announced,
position,
Expand Down Expand Up @@ -1109,7 +1123,7 @@ function buildEntry(segment: EntrySegment, markers: readonly string[]): Result<E
);
}
const owner = ownerOf(
byPath,
sources,
fragments,
announced,
position,
Expand Down Expand Up @@ -1369,12 +1383,17 @@ interface GeneratedOwner extends Admission {

/** Where one recorded effect sits: on which coroutine, and how far in. */
interface EffectSite {
readonly sourceOwner: Json | undefined;
readonly coroutine: string;
readonly order: number;
}

function site(event: Yield, order: number): EffectSite {
return { coroutine: event.coroutineId, order };
return {
coroutine: event.coroutineId,
order,
sourceOwner: event.description[SOURCE_OWNER_FIELD],
};
}

/**
Expand Down Expand Up @@ -1427,24 +1446,12 @@ function descendsFrom(ancestor: string, coroutine: string): boolean {
}

/**
* The one scope an effect's source position belongs to.
*
* Two attributions, and a position states which one it is. A position naming a
* path belongs to the scope admitted from that path; a position naming a
* generated fragment belongs to the scope that fragment's admission created. In
* both, the position chooses the candidates — a path, or the identity the effect
* itself carries — and journal order and coroutine ancestry decide only whether
* a candidate could be its owner: an admission that happened afterwards, or on
* work this effect is not part of, is not an owner however recently it ran. A
* component called once in each of two spawned branches is therefore two owners
* that no effect can confuse, while the same call repeated on one coroutine is
* two that nothing recorded tells apart. Nothing is chosen by the latest
* admission, the current scope, an effect's name or its line and column: a
* binding attached to a guessed owner is a value shown in the wrong place, and
* there is no spelling of "probably this one" that a reader could check.
* Explicit source ownership follows one prior import coordinate. A legacy
* position has no such reference, so only an unambiguous path or generated
* admission can own it; a malformed explicit reference never reaches inference.
*/
function ownerOf(
index: Map<string, Admission[]>,
index: { paths: Map<string, Admission[]>; imports: Map<string, Admission> },
fragments: readonly GeneratedOwner[],
announced: ReadonlySet<string>,
position: ReplPosition | undefined,
Expand All @@ -1459,10 +1466,47 @@ function ownerOf(
),
);
}
if (where.sourceOwner !== undefined) {
return explicitOwnerOf(index.imports, where.sourceOwner, position, where, subject);
}
if (position.path === undefined) {
return generatedOwnerOf(fragments, announced, position, where, subject);
}
return pathOwnerOf(index.get(position.path) ?? [], position, where, subject);
return pathOwnerOf(index.paths.get(position.path) ?? [], position, where, subject);
}

function explicitOwnerOf(
imports: ReadonlyMap<string, Admission>,
reference: Json,
position: ReplPosition,
where: EffectSite,
subject: string,
): Result<{ scope: ScopeDraft; position: ReplPosition }> {
if (
!isJsonObject(reference) ||
Object.keys(reference).length !== 2 ||
typeof reference.coroutineId !== "string" ||
typeof reference.index !== "number" ||
!Number.isSafeInteger(reference.index) ||
reference.index < 0
) {
return Err(new ReplProjectionError(`${subject} carries an unreadable source owner.`));
}
const admission = imports.get(`${reference.coroutineId}:${reference.index}`);
if (admission === undefined || admission.order >= where.order) {
return Err(new ReplProjectionError(`${subject} names no earlier admitted source owner.`));
}
if (position.path === undefined || admission.scope.path !== position.path) {
return Err(
new ReplProjectionError(`${subject} names an owner admitted from a different source.`),
);
}
if (!descendsFrom(admission.coroutine, where.coroutine)) {
return Err(
new ReplProjectionError(`${subject} names a source owner on work it is not part of.`),
);
}
return Ok({ scope: admission.scope, position });
}

/**
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/tests/fixtures/repl/OwnerAnswer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { exists, readTextFile, writeTextFile } from "@effectionx/fs";
import { content, Elicitation } from "@executablemd/core";
import type { Json } from "@executablemd/core";
import { cwd } from "@executablemd/runtime";
import type { Operation } from "effection";
import { join } from "node:path";

export const props = {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
additionalProperties: false,
};

export default function* OwnerAnswer(elementProps: Record<string, Json>): Operation<string> {
if (typeof elementProps.path !== "string") {
throw new Error("the fixture requires its counter path");
}
const path = join(yield* cwd(), elementProps.path);
yield* Elicitation.around({
*elicit([request]) {
const before = (yield* exists(path)) ? yield* readTextFile(path) : "";
const answer = request.message.trim();
yield* writeTextFile(path, `${before}${answer}\n`);
return answer;
},
});
return yield* content();
}
1 change: 1 addition & 0 deletions packages/cli/tests/fixtures/repl/OwnerContent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<Content />
14 changes: 14 additions & 0 deletions packages/cli/tests/fixtures/repl/OwnerEval.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
required: [input]
props:
input:
type: string
returns:
type: string
---

```js eval
const value = props.input;
```

<Return value={value} />
11 changes: 11 additions & 0 deletions packages/cli/tests/fixtures/repl/OwnerLeaf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
required: [input]
props:
input:
type: string
returns:
type: string
---

<Elicit schema={{type: "string"}} as="answer">{props.input}</Elicit>
<Return value={answer} />
11 changes: 11 additions & 0 deletions packages/cli/tests/fixtures/repl/OwnerWrapper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
required: [input]
props:
input:
type: string
returns:
type: string
---

<OwnerLeaf input={props.input} as="nested" />
<Return value={nested} />
47 changes: 47 additions & 0 deletions packages/cli/tests/fixtures/repl/source-ownership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { collect, Elicitation, inlineSource } from "@executablemd/core";
import { executeInstalled } from "@executablemd/core/host";
import { InMemoryStream } from "@executablemd/durable-streams";
import type { DurableEvent } from "@executablemd/durable-streams";
import { scoped } from "effection";
import type { Operation } from "effection";
import { fileURLToPath } from "node:url";

export const OWNER_DIRECTORY = fileURLToPath(new URL("./", import.meta.url));
export const OWNER_ENTRY = [
'<OwnerWrapper input="first" as="first" />',
'<OwnerWrapper input="second" as="second" />',
"{first}/{second}",
].join("\n");
export const OWNER_FIELD = "executablemd.source-owner";

export function ownerRun(
source: string = OWNER_ENTRY,
stream: InMemoryStream = new InMemoryStream(),
answer: (message: string) => Operation<string> = function* (message) {
return message.trim();
},
): Operation<{ output: string; events: DurableEvent[] }> {
return scoped(function* () {
yield* Elicitation.around({
*elicit([request]) {
return yield* answer(request.message);
},
});
const execution = yield* executeInstalled(
{ ...inlineSource(source), stream, includes: [OWNER_DIRECTORY] },
[],
);
const output = String(yield* collect(execution));
return { output, events: yield* stream.readAll() };
});
}

export function legacyOwners(events: readonly DurableEvent[]): DurableEvent[] {
return events.map((event) => {
if (event.type !== "yield") {
return event;
}
const { [OWNER_FIELD]: _owner, ...description } = event.description;
return { ...event, description };
});
}
Loading
Loading