From 435b2da613c21652ab47bdf73ed129eb366c8f7c Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 21 Oct 2026 17:25:44 -0400 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=90=9B=20Attribute=20REPL=20source=20?= =?UTF-8?q?work=20to=20its=20owning=20import=20(#976)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 20 ++ packages/cli/src/repl/model.ts | 96 ++++++--- .../cli/tests/fixtures/repl/OwnerAnswer.ts | 29 +++ .../cli/tests/fixtures/repl/OwnerContent.md | 1 + packages/cli/tests/fixtures/repl/OwnerLeaf.md | 11 + .../cli/tests/fixtures/repl/OwnerWrapper.md | 11 + .../tests/fixtures/repl/source-ownership.ts | 47 +++++ packages/cli/tests/repl-include-pty.test.ts | 131 ++++++++++++ packages/cli/tests/repl-model.test.ts | 191 +++++++++++++++++- .../core/src/agent/function-components.ts | 13 +- packages/core/src/agent/journal.ts | 12 +- packages/core/src/components/Elicit.ts | 8 +- packages/core/src/components/Evaluate.ts | 13 +- packages/core/src/elicit-journal.ts | 6 +- packages/core/src/eval-handler.ts | 4 +- packages/core/src/execute.ts | 42 +++- packages/core/src/expand.ts | 102 ++++++++-- packages/core/src/generated-xmd.ts | 13 +- packages/core/src/invocation-identity.ts | 23 ++- packages/core/src/source-owner.ts | 50 +++++ .../tests/journal-source-position.test.ts | 12 ++ specs/executable-mdx-spec.md | 20 ++ specs/repl-spec.md | 7 + 23 files changed, 802 insertions(+), 60 deletions(-) create mode 100644 packages/cli/tests/fixtures/repl/OwnerAnswer.ts create mode 100644 packages/cli/tests/fixtures/repl/OwnerContent.md create mode 100644 packages/cli/tests/fixtures/repl/OwnerLeaf.md create mode 100644 packages/cli/tests/fixtures/repl/OwnerWrapper.md create mode 100644 packages/cli/tests/fixtures/repl/source-ownership.ts create mode 100644 packages/core/src/source-owner.ts diff --git a/architecture.md b/architecture.md index e91b2e364..fbdbc0a4a 100644 --- a/architecture.md +++ b/architecture.md @@ -6361,6 +6361,26 @@ 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 private expansion arguments carry it through component bodies, branches, +iterations and projection. A component body uses its own import; caller content +uses its captured caller import. Existing private invocation state and block +inputs deliver the reference to the operation recording it. 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 diff --git a/packages/cli/src/repl/model.ts b/packages/cli/src/repl/model.ts index fb42234a1..01d4847ca 100644 --- a/packages/cli/src/repl/model.ts +++ b/packages/cli/src/repl/model.ts @@ -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 { @@ -741,7 +742,16 @@ function buildEntry(segment: EntrySegment, markers: readonly string[]): Result(); + const sources = { paths: new Map(), imports: new Map() }; + const yields = new Map(); + 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[] = []; /** @@ -780,7 +790,9 @@ function buildEntry(segment: EntrySegment, markers: readonly string[]): Result, + index: { paths: Map; imports: Map }, fragments: readonly GeneratedOwner[], announced: ReadonlySet, position: ReplPosition | undefined, @@ -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, + 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 }); } /** diff --git a/packages/cli/tests/fixtures/repl/OwnerAnswer.ts b/packages/cli/tests/fixtures/repl/OwnerAnswer.ts new file mode 100644 index 000000000..57582015e --- /dev/null +++ b/packages/cli/tests/fixtures/repl/OwnerAnswer.ts @@ -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): Operation { + 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(); +} diff --git a/packages/cli/tests/fixtures/repl/OwnerContent.md b/packages/cli/tests/fixtures/repl/OwnerContent.md new file mode 100644 index 000000000..772dbb91c --- /dev/null +++ b/packages/cli/tests/fixtures/repl/OwnerContent.md @@ -0,0 +1 @@ + diff --git a/packages/cli/tests/fixtures/repl/OwnerLeaf.md b/packages/cli/tests/fixtures/repl/OwnerLeaf.md new file mode 100644 index 000000000..8d204507f --- /dev/null +++ b/packages/cli/tests/fixtures/repl/OwnerLeaf.md @@ -0,0 +1,11 @@ +--- +required: [input] +props: + input: + type: string +returns: + type: string +--- + +{props.input} + diff --git a/packages/cli/tests/fixtures/repl/OwnerWrapper.md b/packages/cli/tests/fixtures/repl/OwnerWrapper.md new file mode 100644 index 000000000..f4124f6d6 --- /dev/null +++ b/packages/cli/tests/fixtures/repl/OwnerWrapper.md @@ -0,0 +1,11 @@ +--- +required: [input] +props: + input: + type: string +returns: + type: string +--- + + + diff --git a/packages/cli/tests/fixtures/repl/source-ownership.ts b/packages/cli/tests/fixtures/repl/source-ownership.ts new file mode 100644 index 000000000..9ee3e5d55 --- /dev/null +++ b/packages/cli/tests/fixtures/repl/source-ownership.ts @@ -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 = [ + '', + '', + "{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 = 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 }; + }); +} diff --git a/packages/cli/tests/repl-include-pty.test.ts b/packages/cli/tests/repl-include-pty.test.ts index 24b4fa1d5..635fe17a9 100644 --- a/packages/cli/tests/repl-include-pty.test.ts +++ b/packages/cli/tests/repl-include-pty.test.ts @@ -1,3 +1,4 @@ +import { OWNER_DIRECTORY, OWNER_ENTRY } from "./fixtures/repl/source-ownership.ts"; import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { @@ -261,3 +262,133 @@ describe("REPL includes through the production source terminal", () => { } }); }); + +describe("REPL sequential source owners through a real terminal (#976)", () => { + it("SO3–SO4: inspect both nested calls, then reopen completed work in another process without repeating its provider", function* () { + const root = yield* useTempDirectory("xmd-o-"); + const workspace = join(root, "workspace"); + const includes = join(root, "components"); + const home = join(root, "home"); + for (const directory of [workspace, includes, home]) { + yield* ensureDir(directory); + } + for (const file of ["OwnerWrapper.md", "OwnerLeaf.md", "OwnerAnswer.ts"]) { + yield* writeTextFile(join(includes, file), yield* readTextFile(join(OWNER_DIRECTORY, file))); + } + let cache = ""; + if (cliRuntime() === "deno") { + const info: unknown = JSON.parse((yield* runShell("deno info --json").expect()).stdout); + if ( + typeof info !== "object" || + info === null || + !("denoDir" in info) || + typeof info.denoDir !== "string" + ) { + throw new Error("deno info supplied no prepared cache path"); + } + cache = `DENO_DIR=${shellQuote(info.denoDir)} `; + } + const line = (args: string[]): string => + `cd ${shellQuote(workspace)} && ${cache}${cliShellCommand(["repl", ...args, "--include", includes, "--deny-all"])}`; + const source = `\n${OWNER_ENTRY}\n`; + let location = ""; + let history = ""; + let retained = ""; + function* activateKey(pty: Pty, label: string, occurrence = 0): Operation { + for (let step = 0; step < 160; step++) { + const screen = yield* pty.shows(); + const matching = screen.filter((line) => { + const at = line.indexOf(label); + return at >= 0 && (!label.startsWith("component ") || at < 36); + }); + const row = matching[occurrence]; + const focused = label.startsWith("component ") + ? row !== undefined && row.slice(0, row.indexOf(label)).includes(">") + : screen.some((line) => { + for (let at = line.indexOf(">"); at !== -1; at = line.indexOf(">", at + 1)) { + if ( + line + .slice(at + 1) + .trimStart() + .replace(/^\* /, "") + .startsWith(label) + ) { + return true; + } + } + return false; + }); + if (focused) { + yield* pty.type("\r"); + yield* sleep(150); + return; + } + yield* pty.type("\t"); + yield* sleep(40); + } + throw new Error( + `focus never reached ${label} (${occurrence}): ${JSON.stringify(yield* pty.shows())}`, + ); + } + function* inspect(pty: Pty): Operation { + for (const [index, expected] of ["first", "second"].entries()) { + yield* activateKey(pty, "component OwnerLeaf", index); + yield* showingAcross(pty, ' { + yield* activateKey(pty, "[exit]"); + const deadline = Date.now() + ACT_MS; + while ((yield* pty.status()) === undefined) { + if (Date.now() > deadline) { + throw new Error("the terminal journey did not exit"); + } + yield* sleep(150); + } + expect(yield* pty.status()).toBe(0); + const screen = (yield* pty.shows()).join("\n"); + const found = screen.match(/xmd repl '(xmd:\/\/repl\/[^']+)'/); + if (found?.[1] === undefined) { + throw new Error(`no printed location: ${screen}`); + } + return found[1]; + } + yield* scoped(function* () { + const pty = yield* allocate(line([]), home); + yield* showing(pty, "Ready for Entry 1", REACH_MS); + history = yield* historyPath(home); + yield* submit(pty, source); + retained = yield* completed(pty, history, 1); + expect(modelOf(retained).terminal?.output?.trim()).toBe("first/second"); + expect(yield* readTextFile(join(workspace, "provider-calls.txt"))).toBe("first\nsecond\n"); + yield* inspect(pty); + location = yield* finish(pty); + }); + yield* rm(includes, { recursive: true }); + yield* scoped(function* () { + const pty = yield* allocate(line([location]), home); + yield* showing(pty, "Ready for Entry 2", REACH_MS); + yield* inspect(pty); + yield* finish(pty); + }); + expect(yield* readTextFile(history)).toBe(retained); + expect(yield* readTextFile(join(workspace, "provider-calls.txt"))).toBe("first\nsecond\n"); + expect(modelOf(yield* readTextFile(history))).toEqual(modelOf(retained)); + }); +}); diff --git a/packages/cli/tests/repl-model.test.ts b/packages/cli/tests/repl-model.test.ts index 6e38a5dbc..f310ecece 100644 --- a/packages/cli/tests/repl-model.test.ts +++ b/packages/cli/tests/repl-model.test.ts @@ -1,3 +1,11 @@ +import { scoped, spawn, suspend, withResolvers } from "effection"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { + legacyOwners, + OWNER_ENTRY, + OWNER_FIELD, + ownerRun, +} from "./fixtures/repl/source-ownership.ts"; /** * What the REPL knows, and where it learned it (#848 M1, M2). * @@ -414,7 +422,7 @@ describe("REPL model: the histories it refuses", () => { column: 1, }, })); - expect(refusal(elsewhere)).toContain("never admitted"); + expect(refusal(elsewhere)).toContain("different source"); } }); @@ -851,7 +859,7 @@ describe("REPL model: the Agent histories it refuses", () => { ...description, "executablemd.source-position": { path: "somewhere-else.md", offset: 0, line: 1, column: 1 }, })); - expect(refusal(elsewhere)).toContain("never admitted"); + expect(refusal(elsewhere)).toContain("different source"); const corrupted = withDescription(events, at, (description) => ({ ...description, @@ -1406,3 +1414,182 @@ describe("REPL model: which call of a repeated source owns its work", () => { yield* useTempFileCompiler(); }); }); + +describe("REPL source ownership by import coordinate (#976)", () => { + beforeAll(() => useTempFileCompiler()); + + it("SO1: sequential nested calls keep their own answers at every prefix and after serialization", function* () { + const run = yield* ownerRun(); + expect(run.output.trim()).toBe("first/second"); + for (let end = 1; end <= run.events.length; end++) { + const events = run.events.slice(0, end); + const model = projected(events); + expect(projected(copied(events))).toEqual(model); + const wrappers = only(model)?.scopes ?? []; + for (const [index, wrapper] of wrappers.entries()) { + const leaf = wrapper.scopes[0]; + expect(leaf?.name ?? "OwnerLeaf").toBe("OwnerLeaf"); + for (const question of leaf?.elicitations ?? []) { + expect(question).toMatchObject({ answer: index === 0 ? "first" : "second" }); + } + } + } + const leafImports = run.events.filter( + (event) => event.type === "yield" && event.description.name === "OwnerLeaf", + ); + expect( + leafImports.map((event) => + event.type === "yield" ? event.description[OWNER_FIELD] : undefined, + ), + ).toEqual([ + { coroutineId: "root", index: 1 }, + { coroutineId: "root", index: 5 }, + ]); + const entry = only(projected(run.events)); + expect(entry?.scopes.map((wrapper) => wrapper.scopes.map((leaf) => leaf.name))).toEqual([ + ["OwnerLeaf"], + ["OwnerLeaf"], + ]); + expect(entry?.bindings).toEqual([ + { name: "first", value: "first" }, + { name: "second", value: "second" }, + ]); + }); + + it("SO2: repeated loop sites and caller Content keep their lexical source owner", function* () { + const source = + '\n'; + const run = yield* ownerRun(source); + const entry = only(projected(run.events)); + expect(entry?.scopes.map((scope) => scope.name)).toEqual([ + "OwnerWrapper", + "OwnerWrapper", + "OwnerContent", + "OwnerWrapper", + ]); + expect(entry?.scopes[2].scopes).toEqual([]); + expect( + entry?.scopes + .filter((scope) => scope.name === "OwnerWrapper") + .map((scope) => scope.scopes[0]?.elicitations[0]), + ).toMatchObject([{ answer: "same" }, { answer: "same" }, { answer: "caller" }]); + const wrappers = run.events.filter( + (event) => event.type === "yield" && event.description.name === "OwnerWrapper", + ); + expect(wrappers).toHaveLength(3); + const yields = run.events.filter((event) => event.type === "yield"); + const wrapperCoordinates = yields.flatMap((event, index) => + event.description.name === "OwnerWrapper" ? [{ coroutineId: event.coroutineId, index }] : [], + ); + const leafOwners = yields + .filter((event) => event.description.name === "OwnerLeaf") + .map((event) => event.description[OWNER_FIELD]); + expect(leafOwners).toEqual(wrapperCoordinates); + expect( + wrappers[0].type === "yield" && wrappers[0].description["executablemd.source-position"], + ).toEqual( + wrappers[1].type === "yield" && wrappers[1].description["executablemd.source-position"], + ); + + for (const event of wrappers) { + if (event.type === "yield") { + expect(event.description[OWNER_FIELD]).toEqual({ coroutineId: "root", index: 0 }); + } + } + }); + + it("SO5: an interrupted legacy prefix continues with links to its existing import coordinates", function* () { + const reached = withResolvers(); + const source = 'ack\n' + OWNER_ENTRY; + const stream = new InMemoryStream(); + let performed = 0; + let prefix: DurableEvent[] = []; + yield* scoped(function* () { + const running = yield* spawn(() => + ownerRun(source, stream, function* (message) { + if (message.trim() === "first") { + reached.resolve(); + yield* suspend(); + } + performed += 1; + return message.trim(); + }), + ); + yield* reached.operation; + prefix = yield* stream.readAll(); + yield* running.halt(); + }); + expect(performed).toBe(1); + expect(prefix.every((event) => event.type !== "close")).toBe(true); + for (const old of [false, true]) { + const initial = old ? legacyOwners(prefix) : copied(prefix); + const continuing = new InMemoryStream(initial); + const continued = yield* ownerRun(source, continuing, function* (message) { + performed += 1; + return message.trim(); + }); + expect(continued.output.trim()).toBe("first/second"); + expect(continued.events.slice(0, initial.length)).toEqual(initial); + const model = projected(continued.events); + expect( + only(model)?.scopes.map((wrapper) => wrapper.scopes[0]?.elicitations[0]), + ).toMatchObject([{ answer: "first" }, { answer: "second" }]); + const suffix = continued.events.slice(initial.length); + const question = suffix.find( + (event) => event.type === "yield" && event.description.type === "elicit", + ); + if (question?.type !== "yield") { + throw new Error("the suffix records its second answer"); + } + const owner = question.description[OWNER_FIELD]; + expect(owner).toEqual({ coroutineId: "root", index: initial.length - 2 }); + } + expect(performed).toBe(5); + }); + + it("SO6: legacy uniqueness, legacy ambiguity and invalid explicit links retain their distinct refusals", function* () { + const reference = yield* referenceEvents(); + expect(projected(legacyOwners(reference))).toEqual(projected(reference)); + const run = yield* ownerRun(); + expect(refusal(legacyOwners(run.events))).toContain("more than once"); + const at = run.events.findIndex( + (event) => event.type === "yield" && event.description.name === "OwnerLeaf", + ); + const invalid: Json[] = [ + null, + {}, + { coroutineId: "root", index: -1 }, + { coroutineId: "root", index: 999 }, + { coroutineId: "root", index: 5 }, + { coroutineId: "missing", index: 0 }, + { coroutineId: "root", index: 0 }, + ]; + for (const reference of invalid) { + expect( + refusal( + withDescription(run.events, at, (description) => ({ + ...description, + [OWNER_FIELD]: reference, + })), + ), + ).toMatch(/owner/); + } + const legacy = [...entryAdmitted(), calledPlan("root.0"), askedInPlan("root.1")]; + expect( + refusal( + withDescription(legacy, 2, (description) => ({ + ...description, + [OWNER_FIELD]: { coroutineId: "root.0", index: 0 }, + })), + ), + ).toContain("not part of"); + expect( + refusal([ + ...entryAdmitted(), + calledPlan("root.0"), + calledPlan("root.0"), + askedInPlan("root.0"), + ]), + ).toContain("more than once"); + }); +}); diff --git a/packages/core/src/agent/function-components.ts b/packages/core/src/agent/function-components.ts index ff944eeb0..fe662bc9d 100644 --- a/packages/core/src/agent/function-components.ts +++ b/packages/core/src/agent/function-components.ts @@ -1,3 +1,4 @@ +import { invocationSourceOwner } from "../invocation-identity.ts"; /** * The agent components (specs/acp-client-spec.md §Components). * @@ -302,7 +303,10 @@ export function sessionComponent(claim: IdentityClaimant): FunctionComponent { }; } -export function* Prompt(props: Record): Operation { +export function* Prompt( + props: Record, + invocation?: ComponentInvocation, +): Operation { // Content first, and before anything else happens: a prompt whose text failed // to render was never asked, so nothing may be resolved, sequenced, journaled // or sent on its behalf. An empty wrapper still beats the text prop. @@ -347,7 +351,12 @@ export function* Prompt(props: Record): Operation { // is unchanged, and this is what a host may retain beside it. const carried: Carried = {}; const record = yield* persistPrompt( - { name: `prompt:${location}#${ordinal}`, input: text, position: expansion.position }, + { + name: `prompt:${location}#${ordinal}`, + input: text, + position: expansion.position, + sourceOwner: invocationSourceOwner(invocation), + }, () => runPrompt(text, options, sequence, throwOnError, carried), () => carried.association, () => carried.begun, diff --git a/packages/core/src/agent/journal.ts b/packages/core/src/agent/journal.ts index 8c6ea877c..35f4ad3dd 100644 --- a/packages/core/src/agent/journal.ts +++ b/packages/core/src/agent/journal.ts @@ -73,7 +73,8 @@ import type { SerializedPromptFailure } from "./errors.ts"; import { AgentInternal } from "./internal.ts"; import { HostObservationFailure } from "./publication.ts"; import type { AgentPromptAssociation, AgentPromptHandle } from "./publication.ts"; -import { sourceDescription } from "../source-position.ts"; +import { sourceOwnerDescription } from "../source-owner.ts"; +import type { DurablePosition } from "@executablemd/durable-streams"; import type { SourcePosition } from "../types.ts"; /** The durable effect type every journaled Agent Prompt is recorded under. */ @@ -419,7 +420,12 @@ function permissionDecision(outcome: PermissionOutcome): PermissionOutcome | und } export function* persistPrompt( - identity: { name: string; input: string; position?: Readonly }, + identity: { + name: string; + input: string; + position?: Readonly; + sourceOwner?: DurablePosition; + }, live: () => Operation, association: () => AgentPromptAssociation | undefined = () => undefined, begun: () => AgentPromptHandle = () => undefined, @@ -429,7 +435,7 @@ export function* persistPrompt( type: AGENT_PROMPT, name: identity.name, input: identity.input, - ...sourceDescription(identity.position), + ...sourceOwnerDescription(identity.position, identity.sourceOwner), }, function* (): Operation { return serializePromptRecord(yield* live()); diff --git a/packages/core/src/components/Elicit.ts b/packages/core/src/components/Elicit.ts index ccad34416..442322c5d 100644 --- a/packages/core/src/components/Elicit.ts +++ b/packages/core/src/components/Elicit.ts @@ -1,3 +1,5 @@ +import { invocationSourceOwner } from "../invocation-identity.ts"; +import type { ComponentInvocation } from "../invocation-identity.ts"; /** * `` — ask a person a structured question, without choosing how * (specs/executable-mdx-spec.md §6.16). @@ -87,7 +89,10 @@ export const returns = { $schema: "http://json-schema.org/draft-07/schema#", } satisfies ReturnsSchema; -export default function* Elicit(props: Record): Operation { +export default function* Elicit( + props: Record, + invocation?: ComponentInvocation, +): Operation { const prepared = yield* prepareElicitation(props.schema); const message = yield* content(); @@ -98,6 +103,7 @@ export default function* Elicit(props: Record): Operation { fingerprint: questionFingerprint({ schema: prepared.schema, message }), schema: prepared.schema, position: expansion.position, + sourceOwner: invocationSourceOwner(invocation), }; // Before the record is read, so a resumed document cannot bind an answer that diff --git a/packages/core/src/components/Evaluate.ts b/packages/core/src/components/Evaluate.ts index 9c6b4837a..b35857c57 100644 --- a/packages/core/src/components/Evaluate.ts +++ b/packages/core/src/components/Evaluate.ts @@ -90,7 +90,11 @@ import type { GeneratedObservation, GeneratedFragmentRequest, } from "../generated-xmd.ts"; -import { ComponentInvocationError, invocationForm } from "../invocation-identity.ts"; +import { + ComponentInvocationError, + invocationForm, + invocationSourceOwner, +} from "../invocation-identity.ts"; import type { ComponentInvocation, IdentityClaimant, @@ -246,7 +250,12 @@ function evaluate(claim: IdentityClaimant): ProtectedBody { entries.admitted.map((entry) => entry.definition.fn), ); try { - return yield* evaluateProtectedGeneratedXmd(request, narrowedBodies, narrowedSyntax); + return yield* evaluateProtectedGeneratedXmd( + request, + narrowedBodies, + narrowedSyntax, + invocationSourceOwner(invocation), + ); } finally { narrowedBodies?.close(); } diff --git a/packages/core/src/elicit-journal.ts b/packages/core/src/elicit-journal.ts index 668384c92..656094be2 100644 --- a/packages/core/src/elicit-journal.ts +++ b/packages/core/src/elicit-journal.ts @@ -38,7 +38,8 @@ import type { Operation } from "effection"; import { canonicalFingerprint } from "./canonical.ts"; import { isJsonObject, parseJson } from "./json.ts"; -import { sourceDescription } from "./source-position.ts"; +import { sourceOwnerDescription } from "./source-owner.ts"; +import type { DurablePosition } from "@executablemd/durable-streams"; import type { Json, JsonObject, SourcePosition } from "./types.ts"; const ELICIT = "elicit"; @@ -62,6 +63,7 @@ export interface ElicitationQuestion { /** Where the answer was recorded and what question it answered. */ export interface ElicitationIdentity { + sourceOwner?: DurablePosition; /** `path:line:column` — the durable name. */ location: string; fingerprint: string; @@ -154,7 +156,7 @@ export function* persistElicitation( name: recordName(identity.location), input: identity.fingerprint, [ELICITATION_SCHEMA_FIELD]: identity.schema, - ...sourceDescription(identity.position), + ...sourceOwnerDescription(identity.position, identity.sourceOwner), }, function* (): Operation { return parseJson(yield* live()); diff --git a/packages/core/src/eval-handler.ts b/packages/core/src/eval-handler.ts index f09b7673d..d847801e9 100644 --- a/packages/core/src/eval-handler.ts +++ b/packages/core/src/eval-handler.ts @@ -26,7 +26,7 @@ import { validateLiveOverlay, } from "./live-env.ts"; import { EphemeralEval, EphemeralEvalOutputError } from "./modifiers/ephemeral.ts"; -import { sourceDescription } from "./source-position.ts"; +import { codeBlockSourceOwner, sourceOwnerDescription } from "./source-owner.ts"; import type { CodeBlockContext, CodeBlockResult, EvalEnv } from "./types.ts"; function* runEphemeralEval( @@ -155,7 +155,7 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) => type: "eval", name: `eval:${ctx.blockId}`, ...(ctx.language ? { language: ctx.language } : {}), - ...sourceDescription(ctx.position), + ...sourceOwnerDescription(ctx.position, codeBlockSourceOwner(ctx)), }, function* (): Operation { Object.assign(evalEnv.values, bindings); diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 5856e4847..3fdb6dcb4 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -95,8 +95,17 @@ import { filesFatalFailure, useSegmentCauses, } from "./errors.ts"; -import { Component, importComponent, isMissingImportProvider, raise } from "./component-api.ts"; +import { + Component, + importComponent, + importThroughTerminal, + isMissingImportProvider, + raise, +} from "./component-api.ts"; import { sourceDescription } from "./source-position.ts"; +import { sourceOwnerDescription } from "./source-owner.ts"; +import { durablePosition } from "@executablemd/durable-streams"; +import type { DurablePosition } from "@executablemd/durable-streams"; import { emissions, exactly, renderSegment } from "./render.ts"; import { createExactSource } from "./output/exact-source.ts"; import type { ExactSource as ExactSourceRecord } from "./output/exact-source.ts"; @@ -905,15 +914,22 @@ function* durableImportComponent( root: RootDocumentSource | undefined, position: Readonly | undefined, inputs: ImportInputs, + sourceOwner: DurablePosition | undefined, + admitted: ((owner: DurablePosition) => void) | undefined, ): Workflow { // Taken before the durable operation and outside it, because the offer is // canonical core's own and a replay has to reach this the same way the live // run did: the element asking is inside the declaration's body, or it is not. const claimed = name === "__root__" ? undefined : inputs.declared?.claim(name); + const coordinate = yield* ephemeral(durablePosition()); const recorded = yield createDurableOperation( // The root import is the run's own entry rather than an authored element, // so it carries no source however it was reached. - { type: "import_component", name, ...(root ? {} : sourceDescription(position)) }, + { + type: "import_component", + name, + ...(root ? {} : sourceOwnerDescription(position, sourceOwner)), + }, () => selectImport(name, root, inputs, claimed), ); @@ -925,7 +941,9 @@ function* durableImportComponent( throw new Error(name === "__root__" ? UNREADABLE_ROOT_RECORD : UNREADABLE_IMPORT_RECORD); } - return yield* ephemeral(materializeImport(name, selection, inputs, claimed)); + const definition = yield* ephemeral(materializeImport(name, selection, inputs, claimed)); + admitted?.(coordinate); + return definition; } function isFunctionComponent(value: unknown): value is FunctionComponent { @@ -2081,6 +2099,7 @@ function* runValueRoot( environment: ExecutionEnvironment, /** This execution's own import resolution, for the expansions below. */ importTerminal: ComponentImportTerminal, + sourceOwner: DurablePosition | undefined, /** This execution's own binding owner, for the byproduct its body computes. */ computed: ComputedBindings, ): Operation { @@ -2113,6 +2132,7 @@ function* runValueRoot( checkedFailures, environment, importTerminal, + sourceOwner, ownBody, ); const exactRecord = environment.sourceSegments; @@ -2185,9 +2205,16 @@ function* documentWorkflow( // installed by execute maps "__root__" to the run's root document source. // The ephemeral() wrapper bridges typing only — the import inside remains a // durable, journaled operation. + let sourceOwner: DurablePosition | undefined; + let rootAdmissions = 0; const imported = yield* ephemeral( (function* (): Operation { - const imported = yield* importComponent("__root__"); + const imported = yield* importThroughTerminal("__root__", undefined, (name, position) => + importTerminal(name, position, undefined, undefined, (owner) => { + rootAdmissions += 1; + sourceOwner = rootAdmissions === 1 ? owner : undefined; + }), + ); const imports = environment.componentResolution; // Asked only when a tier actually closes this name, exactly as an // ordinary import is. The environment used to be absent altogether for a @@ -2319,6 +2346,7 @@ function* documentWorkflow( checkedFailures, environment, importTerminal, + sourceOwner, computed, ); } @@ -2344,6 +2372,7 @@ function* documentWorkflow( checkedFailures, environment, importTerminal, + sourceOwner, undefined, ); // An empty buffered root emits no output event. @@ -2373,6 +2402,7 @@ function* documentWorkflow( checkedFailures, environment, importTerminal, + sourceOwner, undefined, ); @@ -2949,6 +2979,8 @@ function* executeDocument( name: string, position: Readonly | undefined, selection: ImportSelection | undefined, + sourceOwner?: DurablePosition, + admitted?: (owner: DurablePosition) => void, ): Operation { // Read per import, in the invoking scope, so a component registered // by a nested scope is visible to what that scope expands. @@ -2965,6 +2997,8 @@ function* executeDocument( catalog, guarded: identity.protected, }, + sourceOwner, + admitted, ); // Canonical selection, recorded into the frame of the import that asked // for it. This is the only thing that puts an invocation in one of this diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 8d27a2042..666bf24ba 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -26,7 +26,6 @@ import type { ExecutableCodeBlock, FunctionComponentDefinition, Json, - CodeBlockContext, ReturnsSchema, SourcePosition, } from "./types.ts"; @@ -141,7 +140,8 @@ import { ephemeral, StaleInputError, } from "@executablemd/durable-streams"; -import type { Workflow } from "@executablemd/durable-streams"; +import type { DurablePosition, Workflow } from "@executablemd/durable-streams"; +import { sourceOwnedCodeBlock } from "./source-owner.ts"; import { SchemaValidationError, validateProps, validateReturnValue } from "./validate.ts"; import { isJsonObject, parseJson } from "./json.ts"; import { healSegment } from "./heal.ts"; @@ -201,6 +201,8 @@ export type ComponentImportTerminal = ( name: string, position: Readonly | undefined, selection: ImportSelection | undefined, + sourceOwner?: DurablePosition, + admitted?: (owner: DurablePosition) => void, ) => Operation; export function createBlockCounter(): BlockCounter { @@ -289,6 +291,7 @@ function expandChildrenScoped( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { return scoped(function* () { @@ -313,6 +316,7 @@ function expandChildrenScoped( checkedFailures, environment, imports, + sourceOwner, returnBody, ); }); @@ -385,6 +389,7 @@ interface ProjectionState { } interface ProjectionFrame { + sourceOwner: DurablePosition | undefined; env: EvalEnv | undefined; meta: Record; props: Record; @@ -468,6 +473,7 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { inner: ProjectionHandle | undefined; loop: LoopFrame | undefined; returnFrame: ReturnBody | undefined; + sourceOwner: DurablePosition | undefined; errors: Segment[]; /** * The caller's region, when this projection renders into one. Structural @@ -530,6 +536,7 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { state.checkedFailures, state.environment, state.imports, + options.sourceOwner, options.returnFrame, ); outcome.resolve({ segments: rendered }); @@ -643,6 +650,7 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { state.checkedFailures, state.environment, state.imports, + frame.sourceOwner, request.kind === "markdown" ? undefined : state.callerReturn, ); outcome.resolve({ segments: [...errors, ...rendered] }); @@ -684,6 +692,7 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { inner: state.enclosing, loop: state.callerLoop, returnFrame: state.callerReturn, + sourceOwner: state.caller.sourceOwner, errors: [], owner, path: extendPath(elementPath, { f: "proj" }), @@ -852,6 +861,7 @@ export function expandSegments( // through the ordinary public Component Api, exactly as it always has. undefined, undefined, + undefined, ); } @@ -931,6 +941,7 @@ function* expandListSegments( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { // An execution opens the table its printed errors record their causes in. @@ -955,6 +966,7 @@ function* expandListSegments( checkedFailures, environment, imports, + sourceOwner, returnBody, ); }); @@ -1077,6 +1089,7 @@ function* expandListSegments( checkedFailures, environment, imports, + sourceOwner, returnBody, )), ); @@ -1098,6 +1111,7 @@ function* expandListSegments( checkedFailures, environment, imports, + sourceOwner, returnBody, )), ); @@ -1120,6 +1134,7 @@ function* expandListSegments( checkedFailures, environment, imports, + sourceOwner, returnBody, ); break; @@ -1154,6 +1169,7 @@ function* expandListSegments( checkedFailures, environment, imports, + sourceOwner, returnBody, ); break; @@ -1173,6 +1189,7 @@ function* expandListSegments( checkedFailures, environment, imports, + sourceOwner, returnBody, ); break; @@ -1195,6 +1212,7 @@ function* expandListSegments( checkedFailures, environment, imports, + sourceOwner, returnBody, ); // Which state this is, answered by identity rather than by name: @@ -1242,6 +1260,7 @@ function* expandListSegments( checkedFailures, environment, imports, + sourceOwner, returnBody, ); break; @@ -1280,6 +1299,7 @@ function* expandListSegments( checkedFailures, environment, imports, + sourceOwner, ); break; } @@ -1333,6 +1353,7 @@ function* expandListSegments( checkedFailures, environment, imports, + sourceOwner, returnBody, ); break; @@ -1356,6 +1377,7 @@ function* expandListSegments( checkedFailures, environment, imports, + sourceOwner, returnBody, ); // A printed error the callee produced is data, and stays data here: it @@ -1395,14 +1417,17 @@ function* expandListSegments( // Compose modifier chain from info string and run it. // blockId uses counter.next() for deterministic IDs that // survive per-segment expansion (see spec §6.1 Block ID counter). - const context: CodeBlockContext = { - language: segment.language, - content: interpolatedContent, - blockId: `eval:${parentMeta["componentName"] ?? "root"}:${counter.next()}`, - componentName: parentMeta["componentName"] as string | undefined, - routing: yield* declaredRouting(), - ...(segment.position === undefined ? {} : { position: segment.position }), - }; + const context = sourceOwnedCodeBlock( + { + language: segment.language, + content: interpolatedContent, + blockId: `eval:${parentMeta["componentName"] ?? "root"}:${counter.next()}`, + componentName: parentMeta["componentName"] as string | undefined, + routing: yield* declaredRouting(), + ...(segment.position === undefined ? {} : { position: segment.position }), + }, + sourceOwner, + ); if (bindingName !== undefined) { if (!evalEnv) { @@ -1670,6 +1695,7 @@ function* expandLet( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { // Every one of these is decided from what the author wrote, so the whole @@ -1703,6 +1729,7 @@ function* expandLet( checkedFailures, environment, imports, + sourceOwner, returnBody, ), ); @@ -1815,6 +1842,7 @@ function* expandEach( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -1878,6 +1906,7 @@ function* expandEach( checkedFailures, environment, imports, + sourceOwner, returnBody, ); // A `` in the body exits the enclosing ``, so the remaining @@ -2006,6 +2035,7 @@ function* expandIf( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -2082,6 +2112,7 @@ function* expandIf( checkedFailures, environment, imports, + sourceOwner, returnBody, ); }; @@ -2162,6 +2193,7 @@ function* expandSwitch( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -2236,6 +2268,7 @@ function* expandSwitch( checkedFailures, environment, imports, + sourceOwner, returnBody, ), ); @@ -2314,6 +2347,7 @@ function* expandLoop( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -2375,6 +2409,7 @@ function* expandLoop( checkedFailures, environment, imports, + sourceOwner, returnBody, ); if (frame.broken) { @@ -2499,6 +2534,7 @@ function spawnChild( inherited: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, ): Operation { return scoped(function* () { yield* provideEnv(spawnEnvironment(incoming)); @@ -2531,6 +2567,7 @@ function spawnChild( ledger, environment, imports, + sourceOwner, undefined, ), ); @@ -2577,6 +2614,7 @@ function* expandAll( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -2607,6 +2645,7 @@ function* expandAll( checkedFailures, environment, imports, + sourceOwner, ), ); @@ -2710,6 +2749,7 @@ function* expandPrintErrors( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { const refusal = printErrorsViolations(segment)[0]; @@ -2737,6 +2777,7 @@ function* expandPrintErrors( recoveringLedger(), environment, imports, + sourceOwner, returnBody, ); }); @@ -2776,6 +2817,7 @@ function* expandComponent( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { // Surrounded before resolution, props validation or any body: what an @@ -2806,6 +2848,12 @@ function* expandComponent( } let imported: ComponentDefinition | FunctionComponentDefinition; + let importedOwner: DurablePosition | undefined; + let admissions = 0; + const admitted = (coordinate: DurablePosition): void => { + admissions += 1; + importedOwner = admissions === 1 ? coordinate : undefined; + }; // Opened before the ask and settled the moment it answers, however it // answered: what canonical resolution selected here is what decides whether // this invocation is in one of this execution's identity domains, and nothing @@ -2852,7 +2900,7 @@ function* expandComponent( imports === undefined ? yield* importComponent(name, position) : yield* importThroughTerminal(name, position, (asked, at) => - imports(asked, at, selection), + imports(asked, at, selection, sourceOwner, admitted), ); } finally { offered?.close(); @@ -2953,6 +3001,7 @@ function* expandComponent( checkedFailures, environment, imports, + sourceOwner, returnBody, selected, dispatcher, @@ -3094,12 +3143,14 @@ function* expandComponent( children, narrowedSyntax: undefined, caller: { + sourceOwner, env: capturedCallerEnv, meta: callerMeta, props: callerProps, hideSet, }, authored: { + sourceOwner: importedOwner, env: componentEnv, meta: definition.meta, props: validatedProps, @@ -3188,6 +3239,7 @@ function* expandComponent( checkedFailures, bodyEnvironment, imports, + importedOwner, returnBody, ); }, observed.settled); @@ -3237,6 +3289,7 @@ function* expandComponent( checkedFailures, bodyEnvironment, imports, + importedOwner, returnBody, ); }, observed.settled); @@ -3413,6 +3466,7 @@ function* expandFunctionComponent( inherited: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, /** * The identity domain canonical resolution selected for this invocation. @@ -3594,9 +3648,18 @@ function* expandFunctionComponent( frame, !selfClosing, dispatcher, + sourceOwner, ) : undefined) ?? - issueInvocation(expansion.id, name, selected, frame, !selfClosing, dispatcher); + issueInvocation( + expansion.id, + name, + selected, + frame, + !selfClosing, + dispatcher, + sourceOwner, + ); const dispatchBody = (body: Operation) => environment?.invokeGeneratedComponent === undefined ? body @@ -3608,12 +3671,14 @@ function* expandFunctionComponent( children, narrowedSyntax: undefined, caller: { + sourceOwner, env: undefined, meta: callerMeta, props: callerProps, hideSet, }, authored: { + sourceOwner, env: undefined, meta: callerMeta, props: callerProps, @@ -4483,6 +4548,7 @@ export function* expandBody( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { if (!bodyHasOutput(bodySegments)) { @@ -4499,6 +4565,7 @@ export function* expandBody( checkedFailures, environment, imports, + sourceOwner, returnBody, ); } @@ -4522,6 +4589,7 @@ export function* expandBody( checkedFailures, environment, imports, + sourceOwner, returnBody, ); } else if (chunk.output) { @@ -4539,6 +4607,7 @@ export function* expandBody( checkedFailures, environment, imports, + sourceOwner, returnBody, ); }); @@ -4558,6 +4627,7 @@ export function* expandBody( checkedFailures, environment, imports, + sourceOwner, returnBody, ); }); @@ -4587,6 +4657,7 @@ function runDocumentation( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { return scoped(function* () { @@ -4603,6 +4674,7 @@ function runDocumentation( checkedFailures, environment, imports, + sourceOwner, returnBody, ); }); @@ -4656,6 +4728,7 @@ function* expandValueBody( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { const slots = partitionBySlot(children); @@ -4683,6 +4756,7 @@ function* expandValueBody( checkedFailures, environment, imports, + sourceOwner, ownBody, ); } @@ -4725,6 +4799,7 @@ function* expandInstalledStructural( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment, imports: ComponentImportTerminal | undefined, + sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { const name = segment.name; @@ -4827,6 +4902,7 @@ function* expandInstalledStructural( checkedFailures, environment, imports, + sourceOwner, returnBody, children: segment.children, }); @@ -4880,6 +4956,7 @@ interface RegionContext { readonly checkedFailures: CheckedFailures | undefined; readonly environment: ExecutionEnvironment; readonly imports: ComponentImportTerminal | undefined; + readonly sourceOwner: DurablePosition | undefined; readonly returnBody: ReturnBody | undefined; readonly children: Segment[]; } @@ -5045,6 +5122,7 @@ function* produceRegion( context.checkedFailures, context.environment, context.imports, + context.sourceOwner, context.returnBody, ); } catch (error) { diff --git a/packages/core/src/generated-xmd.ts b/packages/core/src/generated-xmd.ts index 48a4a4689..5b242da32 100644 --- a/packages/core/src/generated-xmd.ts +++ b/packages/core/src/generated-xmd.ts @@ -139,7 +139,8 @@ import { GeneratedDataExpressions, validateDataExpression } from "./generated-ex import { capturedBinding } from "./invocation-rules.ts"; import { renderSegments } from "./render.ts"; import { scanSegments } from "./scanner.ts"; -import { sourceDescription } from "./source-position.ts"; +import { sourceOwnerDescription } from "./source-owner.ts"; +import type { DurablePosition } from "@executablemd/durable-streams"; import { RESERVED_STRUCTURAL } from "./structural.ts"; // The ordinary source rules, read rather than reimplemented: a construct means // one thing whether a person wrote it or an Agent did. @@ -2231,13 +2232,14 @@ function* persistAdmission( position: Readonly | undefined, marker: RecordMarker, language: AdmissionLanguage, + sourceOwner: DurablePosition | undefined, ): Operation { const stored = yield createDurableOperation( { type: GENERATED_XMD, name: `generated:${id}`, input: policyRecord(policy, marker), - ...sourceDescription(position), + ...sourceOwnerDescription(position, sourceOwner), }, () => admitSource(id, source, table, policy, marker, language), ); @@ -2413,6 +2415,7 @@ function expand( environment, undefined, undefined, + undefined, ); return renderSegments(owner ?? []); } @@ -2431,6 +2434,7 @@ function expand( // narrowed provider installed above, and it inherits nothing of the // document execution that admitted it. undefined, + undefined, // A generated fragment is the engine's own text, so it owns no value body // and a written into it satisfies no declaration. undefined, @@ -2461,6 +2465,7 @@ export function* evaluateProtectedGeneratedXmd( request: GeneratedFragmentRequest, componentRouting: ComponentRouting | undefined, syntax: SyntaxReference | undefined, + sourceOwner?: DurablePosition, ): Operation { return yield* evaluateGenerated( request, @@ -2469,6 +2474,8 @@ export function* evaluateProtectedGeneratedXmd( componentRouting, syntax, undefined, + undefined, + sourceOwner, ); } @@ -2490,6 +2497,7 @@ function* evaluateGenerated( rendering: RootRendering | undefined, /** What this root was resolved to, for a root evaluation. */ resolved?: ResolvedRoot, + sourceOwner?: DurablePosition, ): Operation { const allow = selection(request.allow); const entries = selectedEntries(request, allow); @@ -2533,6 +2541,7 @@ function* evaluateGenerated( request.position, marker, language, + sourceOwner, ); const decided = readAdmission(stored, marker); if (decided === undefined) { diff --git a/packages/core/src/invocation-identity.ts b/packages/core/src/invocation-identity.ts index f41f055ea..ca0e3f92f 100644 --- a/packages/core/src/invocation-identity.ts +++ b/packages/core/src/invocation-identity.ts @@ -1,3 +1,4 @@ +import type { DurablePosition } from "@executablemd/durable-streams"; /** * The identity a trusted host's component names its durable work after * (architecture.md, *Capability-backed invocation identity*). @@ -257,6 +258,7 @@ export interface ComponentRouting { frame: Scope, content: boolean, selection?: FunctionComponent, + sourceOwner?: DurablePosition, ): IssuedInvocation | undefined; /** Close this route and its descendants. */ close(): void; @@ -339,11 +341,19 @@ function createComponentRouting(): ComponentRoutingInstallation { } return route(narrowed, active); }, - issue(fn, id, component, frame, content, selection): IssuedInvocation | undefined { + issue( + fn, + id, + component, + frame, + content, + selection, + sourceOwner, + ): IssuedInvocation | undefined { const entry = active() && typeof fn === "function" ? bodies.get(fn) : undefined; return entry === undefined || entry.domain.component !== component ? undefined - : issueInvocation(id, component, entry.domain, frame, content, selection); + : issueInvocation(id, component, entry.domain, frame, content, selection, sourceOwner); }, close(): void { open = false; @@ -454,6 +464,7 @@ export interface IssuedInvocation { } interface Issuance { + readonly sourceOwner: DurablePosition | undefined; readonly id: string; /** The component this is an invocation of, as the engine resolved it. */ readonly component: string; @@ -795,8 +806,10 @@ export function issueInvocation( frame: Scope, content: boolean, selection?: FunctionComponent, + sourceOwner?: DurablePosition, ): IssuedInvocation { const issuance: Issuance = { + sourceOwner, id, component, content, @@ -1242,3 +1255,9 @@ export function installIdentities( }, }; } + +export function invocationSourceOwner( + invocation: ComponentInvocation | undefined, +): DurablePosition | undefined { + return stateOf(invocation)?.sourceOwner; +} diff --git a/packages/core/src/source-owner.ts b/packages/core/src/source-owner.ts new file mode 100644 index 000000000..bf52f5ffb --- /dev/null +++ b/packages/core/src/source-owner.ts @@ -0,0 +1,50 @@ +/** + * A source import's existing journal coordinate, carried with private expansion + * inputs. The public block context gains no attribution member; its private + * carrier lets the eval terminal retain the reference it was handed. + */ +import type { DurablePosition } from "@executablemd/durable-streams"; +import type { CodeBlockContext, Json, SourcePosition } from "./types.ts"; +import { sourceDescription } from "./source-position.ts"; + +export function sourceOwnerDescription( + position: Readonly | undefined, + owner: DurablePosition | undefined, +): Record { + return { + ...sourceDescription(position), + ...(position?.path === undefined || owner === undefined + ? {} + : { "executablemd.source-owner": { coroutineId: owner.coroutineId, index: owner.index } }), + }; +} + +class OwnedCodeBlock implements CodeBlockContext { + declare language: string; + declare content: string; + declare blockId: string; + declare componentName?: string; + declare routing?: CodeBlockContext["routing"]; + declare position?: Readonly; + readonly #owner: DurablePosition; + + constructor(context: CodeBlockContext, owner: DurablePosition) { + Object.assign(this, context); + this.#owner = owner; + } + + static owner(context: CodeBlockContext): DurablePosition | undefined { + return #owner in context ? context.#owner : undefined; + } +} + +export function sourceOwnedCodeBlock( + context: CodeBlockContext, + owner: DurablePosition | undefined, +): CodeBlockContext { + return owner === undefined ? context : new OwnedCodeBlock(context, owner); +} + +export function codeBlockSourceOwner(context: CodeBlockContext): DurablePosition | undefined { + return OwnedCodeBlock.owner(context); +} diff --git a/packages/core/tests/journal-source-position.test.ts b/packages/core/tests/journal-source-position.test.ts index 87c086558..5f4efeb7a 100644 --- a/packages/core/tests/journal-source-position.test.ts +++ b/packages/core/tests/journal-source-position.test.ts @@ -107,6 +107,18 @@ describe("Tier JSP — authored source in the journal", () => { yield* collect(yield* execute({ path, stream })); const events = yield* stream.readAll(); + for (const event of events) { + if ( + event.type === "yield" && + ["eval", "import_component"].includes(event.description.type) && + event.description.name !== "__root__" + ) { + expect(event.description["executablemd.source-owner"]).toEqual({ + coroutineId: "root", + index: 0, + }); + } + } expect(sourceOf(events, "import_component", "Mark")).toEqual({ path, diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index bc65bb4ce..25c9f84c4 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -11658,6 +11658,26 @@ Workflow history parses the optional field as a `SourcePosition` and never reconstructs it from an expansion ID or current source. Root, Close and trusted-host events may have no authored source. +For file-positioned imports, evaluated blocks, elicitations, Agent prompts and +generated-source admissions, `"executablemd.source-owner"` identifies the +successful import of the source containing the operation. Its value is +`{ coroutineId, index }`, with `index` the zero-based yield index on that +coroutine within the entry. The reference is descriptive metadata, not effect +identity or execution permission; it adds no operation or coroutine. + +Canonical import admission captures its coordinate. Core passes it through +private expansion arguments: a component body uses its own import, structural +regions inherit their source, and projected `` uses the caller's +source import. Generated-fragment positions keep their existing admission ID. + +The REPL follows an explicit reference to exactly one prior successful source +import and requires matching source and coroutine ancestry. A malformed, +missing, forward, wrong-source or unrelated-branch reference refuses without +inference. Without this field, existing unambiguous histories remain readable +and ambiguous histories remain refused. Reading changes no retained bytes and +performs no completed effect. Continuation counts replayed yields, so a new +suffix can reference an import in a legacy prefix at its original coordinate. + A position names **one** source. A file states `path`. An operation inside admitted generated source states `generatedSource`: the id that fragment was admitted under, which is the `generated_xmd` record's own `generated:{id}` name. diff --git a/specs/repl-spec.md b/specs/repl-spec.md index bf697ba98..ad9555921 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -362,6 +362,13 @@ files change or disappear; inspecting them reads no live component source, compiles no eval and asks no Agent. Imported component bytes remain retained for replay. New entries use this command's selected directories. +A component called twice keeps two source readings. Each nested source and +recorded answer belongs to the call that produced it, while caller content +projected through `` keeps its caller's source. Current records name +the owning source import directly. Older records remain readable when their +ownership is unambiguous; an older ambiguous history still refuses without +repairing its bytes or repeating completed work. + ## The packaged Plan, in the REPL's own drawer `` is the same packaged Component `xmd plan` runs, declared by this command From 735776c5b3dfba4414e0d8b76501f8594ba432ae Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 21 Oct 2026 18:15:40 -0400 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=90=9B=20Preserve=20eval=20source=20o?= =?UTF-8?q?wnership=20through=20middleware=20copies=20(#976)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 8 ++- packages/cli/tests/fixtures/repl/OwnerEval.md | 14 +++++ packages/cli/tests/repl-model.test.ts | 57 ++++++++++++++++++- packages/core/src/eval-handler.ts | 5 +- packages/core/src/expand.ts | 25 ++++---- packages/core/src/source-owner.ts | 41 ++----------- specs/executable-mdx-spec.md | 4 +- 7 files changed, 98 insertions(+), 56 deletions(-) create mode 100644 packages/cli/tests/fixtures/repl/OwnerEval.md diff --git a/architecture.md b/architecture.md index fbdbc0a4a..9149e4aaa 100644 --- a/architecture.md +++ b/architecture.md @@ -6369,9 +6369,11 @@ the REPL attributes: the coroutine ID and zero-based yield index of the prior successful source import. Canonical import admission captures this coordinate, and private expansion arguments carry it through component bodies, branches, iterations and projection. A component body uses its own import; caller content -uses its captured caller import. Existing private invocation state and block -inputs deliver the reference to the operation recording it. It grants no -permission and creates no effect, coroutine or identity allocator. +uses its captured caller import. Existing private invocation state delivers the +reference to component operations. Modifier execution binds the reference in the +scoped `SourceOwner` Context, 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 diff --git a/packages/cli/tests/fixtures/repl/OwnerEval.md b/packages/cli/tests/fixtures/repl/OwnerEval.md new file mode 100644 index 000000000..ef6a17946 --- /dev/null +++ b/packages/cli/tests/fixtures/repl/OwnerEval.md @@ -0,0 +1,14 @@ +--- +required: [input] +props: + input: + type: string +returns: + type: string +--- + +```js eval +const value = props.input; +``` + + diff --git a/packages/cli/tests/repl-model.test.ts b/packages/cli/tests/repl-model.test.ts index f310ecece..77d0c718c 100644 --- a/packages/cli/tests/repl-model.test.ts +++ b/packages/cli/tests/repl-model.test.ts @@ -20,7 +20,7 @@ import { import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { useTempFileCompiler } from "@executablemd/core"; +import { Component, useTempFileCompiler } from "@executablemd/core"; import { parseDurableEvent, serializeDurableEvent } from "@executablemd/durable-streams"; import type { DurableEvent, Json } from "@executablemd/durable-streams"; @@ -1498,6 +1498,61 @@ describe("REPL source ownership by import coordinate (#976)", () => { } }); + it("SO7: ordinary middleware copies preserve evaluated blocks' source ownership", function* () { + const source = [ + '', + '', + "", + "```js eval", + 'const callerValue = "caller";', + "```", + "", + "{first}/{second}", + ].join("\n"); + for (const copy of [false, true]) { + yield* scoped(function* () { + let forwarded = 0; + if (copy) { + yield* Component.around({ + *applyModifiers([modifiers, block], next) { + forwarded += 1; + return yield* next(modifiers, { ...block }); + }, + }); + } + const run = yield* ownerRun(source); + expect(run.output.trim()).toBe("first/second"); + expect(forwarded).toBe(copy ? 3 : 0); + const evaluations = run.events.filter( + (event) => event.type === "yield" && event.description.type === "eval", + ); + expect( + evaluations.map((event) => + event.type === "yield" ? event.description[OWNER_FIELD] : undefined, + ), + ).toEqual([ + { coroutineId: "root", index: 1 }, + { coroutineId: "root", index: 3 }, + { coroutineId: "root", index: 0 }, + ]); + for (let end = 1; end <= run.events.length; end++) { + const prefix = run.events.slice(0, end); + expect(projected(copied(prefix))).toEqual(projected(prefix)); + } + const entry = only(projected(copied(run.events))); + if (entry === undefined) { + throw new Error("the journal admits one entry"); + } + expect(entry.scopes.map((scope) => scope.bindings)).toEqual([ + [{ name: "value", value: "first" }], + [{ name: "value", value: "second" }], + [], + ]); + expect(binding(entry, "callerValue")).toBe("caller"); + }); + } + }); + it("SO5: an interrupted legacy prefix continues with links to its existing import coordinates", function* () { const reached = withResolvers(); const source = 'ack\n' + OWNER_ENTRY; diff --git a/packages/core/src/eval-handler.ts b/packages/core/src/eval-handler.ts index d847801e9..2286e712b 100644 --- a/packages/core/src/eval-handler.ts +++ b/packages/core/src/eval-handler.ts @@ -26,7 +26,7 @@ import { validateLiveOverlay, } from "./live-env.ts"; import { EphemeralEval, EphemeralEvalOutputError } from "./modifiers/ephemeral.ts"; -import { codeBlockSourceOwner, sourceOwnerDescription } from "./source-owner.ts"; +import { SourceOwner, sourceOwnerDescription } from "./source-owner.ts"; import type { CodeBlockContext, CodeBlockResult, EvalEnv } from "./types.ts"; function* runEphemeralEval( @@ -117,6 +117,7 @@ function runBlock(blockId: string, block: () => Operation): Operation { export const evalFactory: ModifierFactory = (_params) => (_args, _next) => (function* () { const ctx = yield* useCodeBlock(); + const sourceOwner = yield* ephemeral(SourceOwner.get()); const evalEnv = yield* ephemeral(env); if (!evalEnv) { throw new Error( @@ -155,7 +156,7 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) => type: "eval", name: `eval:${ctx.blockId}`, ...(ctx.language ? { language: ctx.language } : {}), - ...sourceOwnerDescription(ctx.position, codeBlockSourceOwner(ctx)), + ...sourceOwnerDescription(ctx.position, sourceOwner), }, function* (): Operation { Object.assign(evalEnv.values, bindings); diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 666bf24ba..675a3232b 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -141,7 +141,7 @@ import { StaleInputError, } from "@executablemd/durable-streams"; import type { DurablePosition, Workflow } from "@executablemd/durable-streams"; -import { sourceOwnedCodeBlock } from "./source-owner.ts"; +import { SourceOwner } from "./source-owner.ts"; import { SchemaValidationError, validateProps, validateReturnValue } from "./validate.ts"; import { isJsonObject, parseJson } from "./json.ts"; import { healSegment } from "./heal.ts"; @@ -1417,17 +1417,14 @@ function* expandListSegments( // Compose modifier chain from info string and run it. // blockId uses counter.next() for deterministic IDs that // survive per-segment expansion (see spec §6.1 Block ID counter). - const context = sourceOwnedCodeBlock( - { - language: segment.language, - content: interpolatedContent, - blockId: `eval:${parentMeta["componentName"] ?? "root"}:${counter.next()}`, - componentName: parentMeta["componentName"] as string | undefined, - routing: yield* declaredRouting(), - ...(segment.position === undefined ? {} : { position: segment.position }), - }, - sourceOwner, - ); + const context = { + language: segment.language, + content: interpolatedContent, + blockId: `eval:${parentMeta["componentName"] ?? "root"}:${counter.next()}`, + componentName: parentMeta["componentName"] as string | undefined, + routing: yield* declaredRouting(), + ...(segment.position === undefined ? {} : { position: segment.position }), + }; if (bindingName !== undefined) { if (!evalEnv) { @@ -1491,7 +1488,9 @@ function* expandListSegments( } try { - const codeResult = yield* applyModifiers(segment.modifiers, context); + const codeResult = yield* SourceOwner.with(sourceOwner, () => + applyModifiers(segment.modifiers, context), + ); // What the command printed and whether it failed are two separate // questions, and the exit code alone answers the second one (#307). diff --git a/packages/core/src/source-owner.ts b/packages/core/src/source-owner.ts index bf52f5ffb..0dc655f20 100644 --- a/packages/core/src/source-owner.ts +++ b/packages/core/src/source-owner.ts @@ -1,12 +1,11 @@ -/** - * A source import's existing journal coordinate, carried with private expansion - * inputs. The public block context gains no attribution member; its private - * carrier lets the eval terminal retain the reference it was handed. - */ import type { DurablePosition } from "@executablemd/durable-streams"; -import type { CodeBlockContext, Json, SourcePosition } from "./types.ts"; +import { createContext } from "effection"; +import type { Json, SourcePosition } from "./types.ts"; import { sourceDescription } from "./source-position.ts"; +/** Attribution follows modifier execution even when middleware copies block inputs. */ +export const SourceOwner = createContext("executablemd.sourceOwner"); + export function sourceOwnerDescription( position: Readonly | undefined, owner: DurablePosition | undefined, @@ -18,33 +17,3 @@ export function sourceOwnerDescription( : { "executablemd.source-owner": { coroutineId: owner.coroutineId, index: owner.index } }), }; } - -class OwnedCodeBlock implements CodeBlockContext { - declare language: string; - declare content: string; - declare blockId: string; - declare componentName?: string; - declare routing?: CodeBlockContext["routing"]; - declare position?: Readonly; - readonly #owner: DurablePosition; - - constructor(context: CodeBlockContext, owner: DurablePosition) { - Object.assign(this, context); - this.#owner = owner; - } - - static owner(context: CodeBlockContext): DurablePosition | undefined { - return #owner in context ? context.#owner : undefined; - } -} - -export function sourceOwnedCodeBlock( - context: CodeBlockContext, - owner: DurablePosition | undefined, -): CodeBlockContext { - return owner === undefined ? context : new OwnedCodeBlock(context, owner); -} - -export function codeBlockSourceOwner(context: CodeBlockContext): DurablePosition | undefined { - return OwnedCodeBlock.owner(context); -} diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 25c9f84c4..3f27b56ea 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -11668,7 +11668,9 @@ identity or execution permission; it adds no operation or coroutine. Canonical import admission captures its coordinate. Core passes it through private expansion arguments: a component body uses its own import, structural regions inherit their source, and projected `` uses the caller's -source import. Generated-fragment positions keep their existing admission ID. +source import. Modifier execution carries attribution independently of the public +code-block object, so middleware forwarding a shallow copy preserves it. +Generated-fragment positions keep their existing admission ID. The REPL follows an explicit reference to exactly one prior successful source import and requires matching source and coroutine ancestry. A malformed, From da5b866d7e1ab57a1c0c464c3b171e04a4846940 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 21 Oct 2026 18:19:49 -0400 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=90=9B=20Carry=20source=20ownership?= =?UTF-8?q?=20through=20the=20scoped=20Context=20(#976)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 14 +- .../core/src/agent/function-components.ts | 13 +- packages/core/src/agent/journal.ts | 9 +- packages/core/src/components/Elicit.ts | 8 +- packages/core/src/components/Evaluate.ts | 13 +- packages/core/src/elicit-journal.ts | 8 +- packages/core/src/execute.ts | 14 +- packages/core/src/expand.ts | 160 ++++++------------ packages/core/src/generated-xmd.ts | 13 +- packages/core/src/invocation-identity.ts | 23 +-- packages/core/src/source-owner.ts | 2 +- specs/executable-mdx-spec.md | 10 +- 12 files changed, 88 insertions(+), 199 deletions(-) diff --git a/architecture.md b/architecture.md index 9149e4aaa..8c8149ccb 100644 --- a/architecture.md +++ b/architecture.md @@ -6367,13 +6367,13 @@ 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 private expansion arguments carry it through component bodies, branches, -iterations and projection. A component body uses its own import; caller content -uses its captured caller import. Existing private invocation state delivers the -reference to component operations. Modifier execution binds the reference in the -scoped `SourceOwner` Context, so copying a public code-block context preserves -attribution. It grants no permission and creates no effect, coroutine or identity -allocator. +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 diff --git a/packages/core/src/agent/function-components.ts b/packages/core/src/agent/function-components.ts index fe662bc9d..ff944eeb0 100644 --- a/packages/core/src/agent/function-components.ts +++ b/packages/core/src/agent/function-components.ts @@ -1,4 +1,3 @@ -import { invocationSourceOwner } from "../invocation-identity.ts"; /** * The agent components (specs/acp-client-spec.md §Components). * @@ -303,10 +302,7 @@ export function sessionComponent(claim: IdentityClaimant): FunctionComponent { }; } -export function* Prompt( - props: Record, - invocation?: ComponentInvocation, -): Operation { +export function* Prompt(props: Record): Operation { // Content first, and before anything else happens: a prompt whose text failed // to render was never asked, so nothing may be resolved, sequenced, journaled // or sent on its behalf. An empty wrapper still beats the text prop. @@ -351,12 +347,7 @@ export function* Prompt( // is unchanged, and this is what a host may retain beside it. const carried: Carried = {}; const record = yield* persistPrompt( - { - name: `prompt:${location}#${ordinal}`, - input: text, - position: expansion.position, - sourceOwner: invocationSourceOwner(invocation), - }, + { name: `prompt:${location}#${ordinal}`, input: text, position: expansion.position }, () => runPrompt(text, options, sequence, throwOnError, carried), () => carried.association, () => carried.begun, diff --git a/packages/core/src/agent/journal.ts b/packages/core/src/agent/journal.ts index 35f4ad3dd..ddc303118 100644 --- a/packages/core/src/agent/journal.ts +++ b/packages/core/src/agent/journal.ts @@ -46,7 +46,7 @@ * associates nothing. */ -import { createDurableOperation, serializeError } from "@executablemd/durable-streams"; +import { createDurableOperation, ephemeral, serializeError } from "@executablemd/durable-streams"; import type { ActivateDurabilityFailure, DurableStream, @@ -73,8 +73,7 @@ import type { SerializedPromptFailure } from "./errors.ts"; import { AgentInternal } from "./internal.ts"; import { HostObservationFailure } from "./publication.ts"; import type { AgentPromptAssociation, AgentPromptHandle } from "./publication.ts"; -import { sourceOwnerDescription } from "../source-owner.ts"; -import type { DurablePosition } from "@executablemd/durable-streams"; +import { SourceOwner, sourceOwnerDescription } from "../source-owner.ts"; import type { SourcePosition } from "../types.ts"; /** The durable effect type every journaled Agent Prompt is recorded under. */ @@ -424,18 +423,18 @@ export function* persistPrompt( name: string; input: string; position?: Readonly; - sourceOwner?: DurablePosition; }, live: () => Operation, association: () => AgentPromptAssociation | undefined = () => undefined, begun: () => AgentPromptHandle = () => undefined, ): Workflow { + const sourceOwner = yield* ephemeral(SourceOwner.get()); const stored = yield createDurableOperation( { type: AGENT_PROMPT, name: identity.name, input: identity.input, - ...sourceOwnerDescription(identity.position, identity.sourceOwner), + ...sourceOwnerDescription(identity.position, sourceOwner), }, function* (): Operation { return serializePromptRecord(yield* live()); diff --git a/packages/core/src/components/Elicit.ts b/packages/core/src/components/Elicit.ts index 442322c5d..ccad34416 100644 --- a/packages/core/src/components/Elicit.ts +++ b/packages/core/src/components/Elicit.ts @@ -1,5 +1,3 @@ -import { invocationSourceOwner } from "../invocation-identity.ts"; -import type { ComponentInvocation } from "../invocation-identity.ts"; /** * `` — ask a person a structured question, without choosing how * (specs/executable-mdx-spec.md §6.16). @@ -89,10 +87,7 @@ export const returns = { $schema: "http://json-schema.org/draft-07/schema#", } satisfies ReturnsSchema; -export default function* Elicit( - props: Record, - invocation?: ComponentInvocation, -): Operation { +export default function* Elicit(props: Record): Operation { const prepared = yield* prepareElicitation(props.schema); const message = yield* content(); @@ -103,7 +98,6 @@ export default function* Elicit( fingerprint: questionFingerprint({ schema: prepared.schema, message }), schema: prepared.schema, position: expansion.position, - sourceOwner: invocationSourceOwner(invocation), }; // Before the record is read, so a resumed document cannot bind an answer that diff --git a/packages/core/src/components/Evaluate.ts b/packages/core/src/components/Evaluate.ts index b35857c57..9c6b4837a 100644 --- a/packages/core/src/components/Evaluate.ts +++ b/packages/core/src/components/Evaluate.ts @@ -90,11 +90,7 @@ import type { GeneratedObservation, GeneratedFragmentRequest, } from "../generated-xmd.ts"; -import { - ComponentInvocationError, - invocationForm, - invocationSourceOwner, -} from "../invocation-identity.ts"; +import { ComponentInvocationError, invocationForm } from "../invocation-identity.ts"; import type { ComponentInvocation, IdentityClaimant, @@ -250,12 +246,7 @@ function evaluate(claim: IdentityClaimant): ProtectedBody { entries.admitted.map((entry) => entry.definition.fn), ); try { - return yield* evaluateProtectedGeneratedXmd( - request, - narrowedBodies, - narrowedSyntax, - invocationSourceOwner(invocation), - ); + return yield* evaluateProtectedGeneratedXmd(request, narrowedBodies, narrowedSyntax); } finally { narrowedBodies?.close(); } diff --git a/packages/core/src/elicit-journal.ts b/packages/core/src/elicit-journal.ts index 656094be2..9d171a96e 100644 --- a/packages/core/src/elicit-journal.ts +++ b/packages/core/src/elicit-journal.ts @@ -26,6 +26,7 @@ import { createDurableOperation, + ephemeral, ReplayGuard, StaleInputError, } from "@executablemd/durable-streams"; @@ -38,8 +39,7 @@ import type { Operation } from "effection"; import { canonicalFingerprint } from "./canonical.ts"; import { isJsonObject, parseJson } from "./json.ts"; -import { sourceOwnerDescription } from "./source-owner.ts"; -import type { DurablePosition } from "@executablemd/durable-streams"; +import { SourceOwner, sourceOwnerDescription } from "./source-owner.ts"; import type { Json, JsonObject, SourcePosition } from "./types.ts"; const ELICIT = "elicit"; @@ -63,7 +63,6 @@ export interface ElicitationQuestion { /** Where the answer was recorded and what question it answered. */ export interface ElicitationIdentity { - sourceOwner?: DurablePosition; /** `path:line:column` — the durable name. */ location: string; fingerprint: string; @@ -150,13 +149,14 @@ export function* persistElicitation( identity: ElicitationIdentity, live: () => Operation, ): Workflow { + const sourceOwner = yield* ephemeral(SourceOwner.get()); const stored = yield createDurableOperation( { type: ELICIT, name: recordName(identity.location), input: identity.fingerprint, [ELICITATION_SCHEMA_FIELD]: identity.schema, - ...sourceOwnerDescription(identity.position, identity.sourceOwner), + ...sourceOwnerDescription(identity.position, sourceOwner), }, function* (): Operation { return parseJson(yield* live()); diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 3fdb6dcb4..347987dbb 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -103,7 +103,7 @@ import { raise, } from "./component-api.ts"; import { sourceDescription } from "./source-position.ts"; -import { sourceOwnerDescription } from "./source-owner.ts"; +import { SourceOwner, sourceOwnerDescription } from "./source-owner.ts"; import { durablePosition } from "@executablemd/durable-streams"; import type { DurablePosition } from "@executablemd/durable-streams"; import { emissions, exactly, renderSegment } from "./render.ts"; @@ -914,13 +914,13 @@ function* durableImportComponent( root: RootDocumentSource | undefined, position: Readonly | undefined, inputs: ImportInputs, - sourceOwner: DurablePosition | undefined, admitted: ((owner: DurablePosition) => void) | undefined, ): Workflow { // Taken before the durable operation and outside it, because the offer is // canonical core's own and a replay has to reach this the same way the live // run did: the element asking is inside the declaration's body, or it is not. const claimed = name === "__root__" ? undefined : inputs.declared?.claim(name); + const sourceOwner = yield* ephemeral(SourceOwner.get()); const coordinate = yield* ephemeral(durablePosition()); const recorded = yield createDurableOperation( // The root import is the run's own entry rather than an authored element, @@ -2099,7 +2099,6 @@ function* runValueRoot( environment: ExecutionEnvironment, /** This execution's own import resolution, for the expansions below. */ importTerminal: ComponentImportTerminal, - sourceOwner: DurablePosition | undefined, /** This execution's own binding owner, for the byproduct its body computes. */ computed: ComputedBindings, ): Operation { @@ -2132,7 +2131,6 @@ function* runValueRoot( checkedFailures, environment, importTerminal, - sourceOwner, ownBody, ); const exactRecord = environment.sourceSegments; @@ -2210,7 +2208,7 @@ function* documentWorkflow( const imported = yield* ephemeral( (function* (): Operation { const imported = yield* importThroughTerminal("__root__", undefined, (name, position) => - importTerminal(name, position, undefined, undefined, (owner) => { + importTerminal(name, position, undefined, (owner) => { rootAdmissions += 1; sourceOwner = rootAdmissions === 1 ? owner : undefined; }), @@ -2296,6 +2294,7 @@ function* documentWorkflow( const rootPath = ""; const scopedExpansion: Operation = scoped(function* () { + yield* SourceOwner.set(sourceOwner); // Before any authored work, and in this scope rather than outside // `durableRun`: this is the scope `Execution.document` has already // installed the document's runtime services in — its Agent provider, its @@ -2346,7 +2345,6 @@ function* documentWorkflow( checkedFailures, environment, importTerminal, - sourceOwner, computed, ); } @@ -2372,7 +2370,6 @@ function* documentWorkflow( checkedFailures, environment, importTerminal, - sourceOwner, undefined, ); // An empty buffered root emits no output event. @@ -2402,7 +2399,6 @@ function* documentWorkflow( checkedFailures, environment, importTerminal, - sourceOwner, undefined, ); @@ -2979,7 +2975,6 @@ function* executeDocument( name: string, position: Readonly | undefined, selection: ImportSelection | undefined, - sourceOwner?: DurablePosition, admitted?: (owner: DurablePosition) => void, ): Operation { // Read per import, in the invoking scope, so a component registered @@ -2997,7 +2992,6 @@ function* executeDocument( catalog, guarded: identity.protected, }, - sourceOwner, admitted, ); // Canonical selection, recorded into the frame of the import that asked diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 675a3232b..f7e641726 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -26,6 +26,7 @@ import type { ExecutableCodeBlock, FunctionComponentDefinition, Json, + CodeBlockContext, ReturnsSchema, SourcePosition, } from "./types.ts"; @@ -201,7 +202,6 @@ export type ComponentImportTerminal = ( name: string, position: Readonly | undefined, selection: ImportSelection | undefined, - sourceOwner?: DurablePosition, admitted?: (owner: DurablePosition) => void, ) => Operation; @@ -291,7 +291,6 @@ function expandChildrenScoped( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { return scoped(function* () { @@ -316,7 +315,6 @@ function expandChildrenScoped( checkedFailures, environment, imports, - sourceOwner, returnBody, ); }); @@ -511,6 +509,7 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { const task = contentScope.scope.run(function* () { try { yield* ErrorMode.set(options.mode); + yield* SourceOwner.set(options.sourceOwner); if (state.narrowedSyntax !== undefined) { yield* useNarrowedSyntax(state.narrowedSyntax); } @@ -536,7 +535,6 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { state.checkedFailures, state.environment, state.imports, - options.sourceOwner, options.returnFrame, ); outcome.resolve({ segments: rendered }); @@ -614,6 +612,7 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { const task = contentScope.scope.run(function* () { try { yield* ErrorMode.set(mode); + yield* SourceOwner.set(frame.sourceOwner); if (state.narrowedSyntax !== undefined) { yield* useNarrowedSyntax(state.narrowedSyntax); } @@ -650,7 +649,6 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { state.checkedFailures, state.environment, state.imports, - frame.sourceOwner, request.kind === "markdown" ? undefined : state.callerReturn, ); outcome.resolve({ segments: [...errors, ...rendered] }); @@ -861,7 +859,6 @@ export function expandSegments( // through the ordinary public Component Api, exactly as it always has. undefined, undefined, - undefined, ); } @@ -941,7 +938,6 @@ function* expandListSegments( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { // An execution opens the table its printed errors record their causes in. @@ -966,7 +962,6 @@ function* expandListSegments( checkedFailures, environment, imports, - sourceOwner, returnBody, ); }); @@ -1089,7 +1084,6 @@ function* expandListSegments( checkedFailures, environment, imports, - sourceOwner, returnBody, )), ); @@ -1111,7 +1105,6 @@ function* expandListSegments( checkedFailures, environment, imports, - sourceOwner, returnBody, )), ); @@ -1134,7 +1127,6 @@ function* expandListSegments( checkedFailures, environment, imports, - sourceOwner, returnBody, ); break; @@ -1169,7 +1161,6 @@ function* expandListSegments( checkedFailures, environment, imports, - sourceOwner, returnBody, ); break; @@ -1189,7 +1180,6 @@ function* expandListSegments( checkedFailures, environment, imports, - sourceOwner, returnBody, ); break; @@ -1212,7 +1202,6 @@ function* expandListSegments( checkedFailures, environment, imports, - sourceOwner, returnBody, ); // Which state this is, answered by identity rather than by name: @@ -1260,7 +1249,6 @@ function* expandListSegments( checkedFailures, environment, imports, - sourceOwner, returnBody, ); break; @@ -1299,7 +1287,6 @@ function* expandListSegments( checkedFailures, environment, imports, - sourceOwner, ); break; } @@ -1353,7 +1340,6 @@ function* expandListSegments( checkedFailures, environment, imports, - sourceOwner, returnBody, ); break; @@ -1377,7 +1363,6 @@ function* expandListSegments( checkedFailures, environment, imports, - sourceOwner, returnBody, ); // A printed error the callee produced is data, and stays data here: it @@ -1417,7 +1402,7 @@ function* expandListSegments( // Compose modifier chain from info string and run it. // blockId uses counter.next() for deterministic IDs that // survive per-segment expansion (see spec §6.1 Block ID counter). - const context = { + const context: CodeBlockContext = { language: segment.language, content: interpolatedContent, blockId: `eval:${parentMeta["componentName"] ?? "root"}:${counter.next()}`, @@ -1488,9 +1473,7 @@ function* expandListSegments( } try { - const codeResult = yield* SourceOwner.with(sourceOwner, () => - applyModifiers(segment.modifiers, context), - ); + const codeResult = yield* applyModifiers(segment.modifiers, context); // What the command printed and whether it failed are two separate // questions, and the exit code alone answers the second one (#307). @@ -1694,7 +1677,6 @@ function* expandLet( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { // Every one of these is decided from what the author wrote, so the whole @@ -1728,7 +1710,6 @@ function* expandLet( checkedFailures, environment, imports, - sourceOwner, returnBody, ), ); @@ -1841,7 +1822,6 @@ function* expandEach( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -1905,7 +1885,6 @@ function* expandEach( checkedFailures, environment, imports, - sourceOwner, returnBody, ); // A `` in the body exits the enclosing ``, so the remaining @@ -2034,7 +2013,6 @@ function* expandIf( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -2111,7 +2089,6 @@ function* expandIf( checkedFailures, environment, imports, - sourceOwner, returnBody, ); }; @@ -2192,7 +2169,6 @@ function* expandSwitch( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -2267,7 +2243,6 @@ function* expandSwitch( checkedFailures, environment, imports, - sourceOwner, returnBody, ), ); @@ -2346,7 +2321,6 @@ function* expandLoop( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -2408,7 +2382,6 @@ function* expandLoop( checkedFailures, environment, imports, - sourceOwner, returnBody, ); if (frame.broken) { @@ -2533,7 +2506,6 @@ function spawnChild( inherited: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, ): Operation { return scoped(function* () { yield* provideEnv(spawnEnvironment(incoming)); @@ -2566,7 +2538,6 @@ function spawnChild( ledger, environment, imports, - sourceOwner, undefined, ), ); @@ -2613,7 +2584,6 @@ function* expandAll( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -2644,7 +2614,6 @@ function* expandAll( checkedFailures, environment, imports, - sourceOwner, ), ); @@ -2748,7 +2717,6 @@ function* expandPrintErrors( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { const refusal = printErrorsViolations(segment)[0]; @@ -2776,7 +2744,6 @@ function* expandPrintErrors( recoveringLedger(), environment, imports, - sourceOwner, returnBody, ); }); @@ -2816,7 +2783,6 @@ function* expandComponent( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { // Surrounded before resolution, props validation or any body: what an @@ -2899,7 +2865,7 @@ function* expandComponent( imports === undefined ? yield* importComponent(name, position) : yield* importThroughTerminal(name, position, (asked, at) => - imports(asked, at, selection, sourceOwner, admitted), + imports(asked, at, selection, admitted), ); } finally { offered?.close(); @@ -3000,7 +2966,6 @@ function* expandComponent( checkedFailures, environment, imports, - sourceOwner, returnBody, selected, dispatcher, @@ -3112,6 +3077,7 @@ function* expandComponent( // Use the caller's eval env for the same reason: expression props (e.g. // {pr}) resolve against the scope where the JSX was written. const capturedCallerEnv = callerEvalEnv ?? componentEnv; + const callerSourceOwner = yield* SourceOwner.get(); // A body eval block can render errors away through a string projection // (renderChildren, render, useContent); the buffer records them so an // `as=` capture can be refused (§6.5). @@ -3142,7 +3108,7 @@ function* expandComponent( children, narrowedSyntax: undefined, caller: { - sourceOwner, + sourceOwner: callerSourceOwner, env: capturedCallerEnv, meta: callerMeta, props: callerProps, @@ -3221,27 +3187,28 @@ function* expandComponent( if (returns !== undefined) { let value: Json; try { - value = yield* withInvocation(function* (invocation) { - yield* installInvocation(invocation); - return yield* expandValueBody( - name, - returns, - definition.bodySegments, - children, - definition.meta, - validatedProps, - newHideSet, - counter, - callerEvalEnv ?? undefined, - claimProjection, - path, - checkedFailures, - bodyEnvironment, - imports, - importedOwner, - returnBody, - ); - }, observed.settled); + value = yield* SourceOwner.with(importedOwner, () => + withInvocation(function* (invocation) { + yield* installInvocation(invocation); + return yield* expandValueBody( + name, + returns, + definition.bodySegments, + children, + definition.meta, + validatedProps, + newHideSet, + counter, + callerEvalEnv ?? undefined, + claimProjection, + path, + checkedFailures, + bodyEnvironment, + imports, + returnBody, + ); + }, observed.settled), + ); } catch (error) { // Body fail-fast propagates unchanged; a return-value failure is the // component's own printed error and follows the caller's error mode. @@ -3272,26 +3239,27 @@ function* expandComponent( // Where this body's own segments start, so what it renders can be told apart // from what the caller had already produced into the same array. const renderedFrom = bodyOwner?.length ?? 0; - const expanded = yield* withInvocation(function* (invocation) { - yield* installInvocation(invocation); - return yield* expandBody( - definition.bodySegments, - children, - definition.meta, - validatedProps, - newHideSet, - counter, - callerEvalEnv ?? undefined, - claimProjection, - bodyOwner, - path, - checkedFailures, - bodyEnvironment, - imports, - importedOwner, - returnBody, - ); - }, observed.settled); + const expanded = yield* SourceOwner.with(importedOwner, () => + withInvocation(function* (invocation) { + yield* installInvocation(invocation); + return yield* expandBody( + definition.bodySegments, + children, + definition.meta, + validatedProps, + newHideSet, + counter, + callerEvalEnv ?? undefined, + claimProjection, + bodyOwner, + path, + checkedFailures, + bodyEnvironment, + imports, + returnBody, + ); + }, observed.settled), + ); // Exact source is a provenance, and both halves of it are read here from // things no answer can write: that canonical execution authorized this import @@ -3465,7 +3433,6 @@ function* expandFunctionComponent( inherited: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, /** * The identity domain canonical resolution selected for this invocation. @@ -3587,6 +3554,7 @@ function* expandFunctionComponent( // the mode inside an invocation differ from the mode at its site — governs // the component and not the text somebody else put inside it (§6.9). const siteErrorMode = (yield* ErrorMode.get()) ?? "print"; + const sourceOwner = yield* SourceOwner.get(); const expansion = snapshot(path, name, position); @@ -3647,18 +3615,9 @@ function* expandFunctionComponent( frame, !selfClosing, dispatcher, - sourceOwner, ) : undefined) ?? - issueInvocation( - expansion.id, - name, - selected, - frame, - !selfClosing, - dispatcher, - sourceOwner, - ); + issueInvocation(expansion.id, name, selected, frame, !selfClosing, dispatcher); const dispatchBody = (body: Operation) => environment?.invokeGeneratedComponent === undefined ? body @@ -4547,7 +4506,6 @@ export function* expandBody( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { if (!bodyHasOutput(bodySegments)) { @@ -4564,7 +4522,6 @@ export function* expandBody( checkedFailures, environment, imports, - sourceOwner, returnBody, ); } @@ -4588,7 +4545,6 @@ export function* expandBody( checkedFailures, environment, imports, - sourceOwner, returnBody, ); } else if (chunk.output) { @@ -4606,7 +4562,6 @@ export function* expandBody( checkedFailures, environment, imports, - sourceOwner, returnBody, ); }); @@ -4626,7 +4581,6 @@ export function* expandBody( checkedFailures, environment, imports, - sourceOwner, returnBody, ); }); @@ -4656,7 +4610,6 @@ function runDocumentation( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { return scoped(function* () { @@ -4673,7 +4626,6 @@ function runDocumentation( checkedFailures, environment, imports, - sourceOwner, returnBody, ); }); @@ -4727,7 +4679,6 @@ function* expandValueBody( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { const slots = partitionBySlot(children); @@ -4755,7 +4706,6 @@ function* expandValueBody( checkedFailures, environment, imports, - sourceOwner, ownBody, ); } @@ -4798,7 +4748,6 @@ function* expandInstalledStructural( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment, imports: ComponentImportTerminal | undefined, - sourceOwner: DurablePosition | undefined, returnBody: ReturnBody | undefined, ): Operation { const name = segment.name; @@ -4901,7 +4850,6 @@ function* expandInstalledStructural( checkedFailures, environment, imports, - sourceOwner, returnBody, children: segment.children, }); @@ -4955,7 +4903,6 @@ interface RegionContext { readonly checkedFailures: CheckedFailures | undefined; readonly environment: ExecutionEnvironment; readonly imports: ComponentImportTerminal | undefined; - readonly sourceOwner: DurablePosition | undefined; readonly returnBody: ReturnBody | undefined; readonly children: Segment[]; } @@ -5121,7 +5068,6 @@ function* produceRegion( context.checkedFailures, context.environment, context.imports, - context.sourceOwner, context.returnBody, ); } catch (error) { diff --git a/packages/core/src/generated-xmd.ts b/packages/core/src/generated-xmd.ts index 5b242da32..f3bf128cd 100644 --- a/packages/core/src/generated-xmd.ts +++ b/packages/core/src/generated-xmd.ts @@ -139,8 +139,7 @@ import { GeneratedDataExpressions, validateDataExpression } from "./generated-ex import { capturedBinding } from "./invocation-rules.ts"; import { renderSegments } from "./render.ts"; import { scanSegments } from "./scanner.ts"; -import { sourceOwnerDescription } from "./source-owner.ts"; -import type { DurablePosition } from "@executablemd/durable-streams"; +import { SourceOwner, sourceOwnerDescription } from "./source-owner.ts"; import { RESERVED_STRUCTURAL } from "./structural.ts"; // The ordinary source rules, read rather than reimplemented: a construct means // one thing whether a person wrote it or an Agent did. @@ -2232,8 +2231,8 @@ function* persistAdmission( position: Readonly | undefined, marker: RecordMarker, language: AdmissionLanguage, - sourceOwner: DurablePosition | undefined, ): Operation { + const sourceOwner = yield* SourceOwner.get(); const stored = yield createDurableOperation( { type: GENERATED_XMD, @@ -2327,6 +2326,7 @@ function expand( owner: Segment[] | undefined, ): Operation { return scoped(function* () { + yield* SourceOwner.set(undefined); // A root selects what it renders, so a failure inside it renders as the // document's own partial output rather than throwing the whole evaluation // away. A fragment is admitted *into* a document and keeps throwing. @@ -2415,7 +2415,6 @@ function expand( environment, undefined, undefined, - undefined, ); return renderSegments(owner ?? []); } @@ -2434,7 +2433,6 @@ function expand( // narrowed provider installed above, and it inherits nothing of the // document execution that admitted it. undefined, - undefined, // A generated fragment is the engine's own text, so it owns no value body // and a written into it satisfies no declaration. undefined, @@ -2465,7 +2463,6 @@ export function* evaluateProtectedGeneratedXmd( request: GeneratedFragmentRequest, componentRouting: ComponentRouting | undefined, syntax: SyntaxReference | undefined, - sourceOwner?: DurablePosition, ): Operation { return yield* evaluateGenerated( request, @@ -2474,8 +2471,6 @@ export function* evaluateProtectedGeneratedXmd( componentRouting, syntax, undefined, - undefined, - sourceOwner, ); } @@ -2497,7 +2492,6 @@ function* evaluateGenerated( rendering: RootRendering | undefined, /** What this root was resolved to, for a root evaluation. */ resolved?: ResolvedRoot, - sourceOwner?: DurablePosition, ): Operation { const allow = selection(request.allow); const entries = selectedEntries(request, allow); @@ -2541,7 +2535,6 @@ function* evaluateGenerated( request.position, marker, language, - sourceOwner, ); const decided = readAdmission(stored, marker); if (decided === undefined) { diff --git a/packages/core/src/invocation-identity.ts b/packages/core/src/invocation-identity.ts index ca0e3f92f..f41f055ea 100644 --- a/packages/core/src/invocation-identity.ts +++ b/packages/core/src/invocation-identity.ts @@ -1,4 +1,3 @@ -import type { DurablePosition } from "@executablemd/durable-streams"; /** * The identity a trusted host's component names its durable work after * (architecture.md, *Capability-backed invocation identity*). @@ -258,7 +257,6 @@ export interface ComponentRouting { frame: Scope, content: boolean, selection?: FunctionComponent, - sourceOwner?: DurablePosition, ): IssuedInvocation | undefined; /** Close this route and its descendants. */ close(): void; @@ -341,19 +339,11 @@ function createComponentRouting(): ComponentRoutingInstallation { } return route(narrowed, active); }, - issue( - fn, - id, - component, - frame, - content, - selection, - sourceOwner, - ): IssuedInvocation | undefined { + issue(fn, id, component, frame, content, selection): IssuedInvocation | undefined { const entry = active() && typeof fn === "function" ? bodies.get(fn) : undefined; return entry === undefined || entry.domain.component !== component ? undefined - : issueInvocation(id, component, entry.domain, frame, content, selection, sourceOwner); + : issueInvocation(id, component, entry.domain, frame, content, selection); }, close(): void { open = false; @@ -464,7 +454,6 @@ export interface IssuedInvocation { } interface Issuance { - readonly sourceOwner: DurablePosition | undefined; readonly id: string; /** The component this is an invocation of, as the engine resolved it. */ readonly component: string; @@ -806,10 +795,8 @@ export function issueInvocation( frame: Scope, content: boolean, selection?: FunctionComponent, - sourceOwner?: DurablePosition, ): IssuedInvocation { const issuance: Issuance = { - sourceOwner, id, component, content, @@ -1255,9 +1242,3 @@ export function installIdentities( }, }; } - -export function invocationSourceOwner( - invocation: ComponentInvocation | undefined, -): DurablePosition | undefined { - return stateOf(invocation)?.sourceOwner; -} diff --git a/packages/core/src/source-owner.ts b/packages/core/src/source-owner.ts index 0dc655f20..2fb72d850 100644 --- a/packages/core/src/source-owner.ts +++ b/packages/core/src/source-owner.ts @@ -4,7 +4,7 @@ import type { Json, SourcePosition } from "./types.ts"; import { sourceDescription } from "./source-position.ts"; /** Attribution follows modifier execution even when middleware copies block inputs. */ -export const SourceOwner = createContext("executablemd.sourceOwner"); +export const SourceOwner = createContext("executablemd.source-owner"); export function sourceOwnerDescription( position: Readonly | undefined, diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 3f27b56ea..86389ccf4 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -11665,11 +11665,11 @@ successful import of the source containing the operation. Its value is coroutine within the entry. The reference is descriptive metadata, not effect identity or execution permission; it adds no operation or coroutine. -Canonical import admission captures its coordinate. Core passes it through -private expansion arguments: a component body uses its own import, structural -regions inherit their source, and projected `` uses the caller's -source import. Modifier execution carries attribution independently of the public -code-block object, so middleware forwarding a shallow copy preserves it. +Canonical import admission captures its coordinate. Core binds it in a scoped +Context around the source body: a component body uses its own import, structural +regions inherit their source, and projected `` restores the caller's +source binding. Attribution is independent of the public code-block object, so +middleware forwarding a shallow copy preserves it. Generated-fragment positions keep their existing admission ID. The REPL follows an explicit reference to exactly one prior successful source From d0d62d17afa530f2bea5eb1674c9de34b70daf38 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 21 Oct 2026 19:47:04 -0400 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20Resolve=20temporary=20REPL?= =?UTF-8?q?=20fixture=20dependencies=20under=20Node=20(#976)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/tests/repl-include-pty.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/cli/tests/repl-include-pty.test.ts b/packages/cli/tests/repl-include-pty.test.ts index 635fe17a9..160aa20cf 100644 --- a/packages/cli/tests/repl-include-pty.test.ts +++ b/packages/cli/tests/repl-include-pty.test.ts @@ -12,8 +12,9 @@ import { ensureDir, exists, readTextFile, rm, writeTextFile } from "@effectionx/ import { parseDurableEvent } from "@executablemd/durable-streams"; import { scoped, sleep, until } from "effection"; import type { Operation } from "effection"; -import { readdir } from "node:fs/promises"; +import { readdir, symlink } from "node:fs/promises"; import { join, sep } from "node:path"; +import { fileURLToPath } from "node:url"; import { platform } from "node:os"; import { platformDataRoot } from "../src/repl-assembly.ts"; import { projectRepl } from "../src/repl/model.ts"; @@ -272,6 +273,14 @@ describe("REPL sequential source owners through a real terminal (#976)", () => { for (const directory of [workspace, includes, home]) { yield* ensureDir(directory); } + yield* writeTextFile(join(root, "package.json"), JSON.stringify({ type: "module" })); + yield* until( + symlink( + fileURLToPath(new URL("../../../node_modules", import.meta.url)), + join(root, "node_modules"), + "dir", + ), + ); for (const file of ["OwnerWrapper.md", "OwnerLeaf.md", "OwnerAnswer.ts"]) { yield* writeTextFile(join(includes, file), yield* readTextFile(join(OWNER_DIRECTORY, file))); } From 0f165fb2da28710dc548cbb19c17ffc5c2df2fd9 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 21 Oct 2026 19:54:34 -0400 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=90=9B=20Read=20terminal=20test=20jou?= =?UTF-8?q?rnals=20after=20entry=20settlement=20(#976)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/tests/repl-include-pty.test.ts | 29 +++++++++------------ 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/cli/tests/repl-include-pty.test.ts b/packages/cli/tests/repl-include-pty.test.ts index 160aa20cf..2c3bd606c 100644 --- a/packages/cli/tests/repl-include-pty.test.ts +++ b/packages/cli/tests/repl-include-pty.test.ts @@ -84,24 +84,19 @@ function modelOf(bytes: string) { } function* completed(pty: Pty, history: string, count: number): Operation { - const deadline = Date.now() + ACT_MS; - while (true) { - const bytes = yield* readTextFile(history); - const model = modelOf(bytes); - if ( - model.entries.length === count && - model.entries.every((entry) => entry.terminal?.status === "ok") - ) { - yield* showing(pty, `Ready for Entry ${count + 1}`, ACT_MS); - return bytes; - } - if (Date.now() > deadline) { - throw new Error( - `entries never closed. history=${bytes} screen=${JSON.stringify(yield* pty.shows())}`, - ); - } - yield* sleep(150); + // Ready requires the journal close and the entry's joined teardown. + yield* showing(pty, `Ready for Entry ${count + 1}`, ACT_MS); + const bytes = yield* readTextFile(history); + const model = modelOf(bytes); + if ( + model.entries.length !== count || + model.entries.some((entry) => entry.terminal?.status !== "ok") + ) { + throw new Error( + `entries never closed. history=${bytes} screen=${JSON.stringify(yield* pty.shows())}`, + ); } + return bytes; } function* leave(pty: Pty): Operation {