Skip to content

Commit afe8a5f

Browse files
authored
Merge pull request #899 from taras/agent/retained-repl-repositories
Add repository operations and recorded-result replay to xmd repl
2 parents e61d6ce + 559f446 commit afe8a5f

71 files changed

Lines changed: 14313 additions & 703 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,15 @@ jobs:
239239
- name: Smoke test repository composition with the compiled binary
240240
run: deno run --allow-all --frozen scripts/smoke-run-composition.ts
241241

242+
# A retained REPL entry's repository host is assembled at a runtime-named
243+
# entrypoint too, and reaches further than the ordinary run's: the Git
244+
# host, the credential helper the binary re-enters as a subprocess of
245+
# itself, and the records an interrupted entry is read back from. This
246+
# drives the binary's own `xmd repl` on a pty, publishes out of an ambient
247+
# checkout into a bare remote, leaves, and reopens the location it printed.
248+
- name: Smoke test a REPL entry's repository work with the compiled binary
249+
run: deno test --allow-all --frozen scripts/tests/repl-repositories-compiled.test.ts
250+
242251
# `<Fetch>` resolves from core's registry, requests through the contextual
243252
# Fetch adapter, and detaches the response before binding it. All three
244253
# live in the module graph, so only the binary shows they survived

‎architecture.md‎

Lines changed: 240 additions & 5 deletions
Large diffs are not rendered by default.

‎packages/cli/src/cli.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ import { testingExecutionHost } from "./testing-host.ts";
156156
import type { ChildPlanDeclaration } from "./testing-host.ts";
157157
import { unsupportedRepositories } from "./run-repositories.ts";
158158
import type { RepositoryInstaller } from "./run-repositories.ts";
159+
import type { ReplEntryHostFactory } from "./repl-repositories.ts";
159160
import { EVAL_ALIAS, EVAL_OPTION, evalGrammarError, readEvalFlags } from "./eval-source.ts";
160161
import type { EvalFlags } from "./eval-source.ts";
161162
import { STANDARD_INPUT_FAILURE, STANDARD_INPUT_PATH } from "./standard-input.ts";
@@ -2677,6 +2678,11 @@ function* dispatch(
26772678
workflowHost: WorkflowHost | undefined,
26782679
sessions: MachineSessionAssembly | undefined,
26792680
installRepl: ReplHostInstaller | undefined,
2681+
// What a REPL entry of this host obtains repository operations through, or
2682+
// nothing on a runtime that offers none. Carried rather than called: the
2683+
// `repl` command retains it in its profile, and the entry task builds the
2684+
// host.
2685+
replEntryHost: ReplEntryHostFactory | undefined,
26802686
/**
26812687
* What this invocation's Plugins installed.
26822688
*
@@ -2923,7 +2929,7 @@ function* dispatch(
29232929
// vocabulary and the ceiling a generated fragment runs under. Nothing is
29242930
// assembled again per entry, so two entries of one session cannot run
29252931
// under different rules.
2926-
const profile = yield* assembleReplProfile(replStack, plugins);
2932+
const profile = yield* assembleReplProfile(replStack, plugins, {}, replEntryHost);
29272933
yield* installRepl();
29282934
const ran = yield* runReplProgram({
29292935
profile,
@@ -3330,6 +3336,13 @@ export function* runXmd(
33303336
// names none has no REPL, and says so rather than opening a terminal it cannot
33313337
// restore.
33323338
installRepl?: ReplHostInstaller,
3339+
// How a REPL entry of this host obtains repository operations. A factory, so
3340+
// the host belongs to the executing entry's own task rather than to the
3341+
// command: two entries of one session are two owners, and a cold view builds
3342+
// none at all. Deno and the compiled binary supply the retained one; Node and
3343+
// Bun supply none, so an entry there refuses repository work before a lock, a
3344+
// credential, a subprocess or a request exists.
3345+
replEntryHost?: ReplEntryHostFactory,
33333346
): Operation<void> {
33343347
// Before every scanner and before anything reads a path: what a command line
33353348
// selects is read from the argv the caller wrote, and the tokens that
@@ -3364,6 +3377,7 @@ export function* runXmd(
33643377
installWorkflowHost,
33653378
sessions,
33663379
installRepl,
3380+
replEntryHost,
33673381
);
33683382
}
33693383

@@ -3426,6 +3440,7 @@ function* runCommand(
34263440
installWorkflowHost: HostWorkflowInstaller,
34273441
sessions: MachineSessionAssembly | undefined,
34283442
installRepl: ReplHostInstaller | undefined,
3443+
replEntryHost: ReplEntryHostFactory | undefined,
34293444
): Operation<void> {
34303445
// First, so that no later scanner — help, properties, agent flags — can
34313446
// mistake the inline document's own text for an option.
@@ -3491,6 +3506,7 @@ function* runCommand(
34913506
workflowHost,
34923507
sessions,
34933508
installRepl,
3509+
replEntryHost,
34943510
plugins,
34953511
);
34963512

‎packages/cli/src/compiled.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { importPluginModule } from "./host-plugin-modules.ts";
1616
import { compiledUpgradeAssembly } from "./compiled-upgrade.ts";
1717
import { useMachineSessions } from "./session-coordinator.ts";
1818
import { useDenoWorkflowHost } from "./deno-workflow.ts";
19-
import { denoRunRepositories } from "./deno-repositories.ts";
19+
import { denoReplEntryHost, denoRunRepositories } from "./deno-repositories.ts";
2020
import { isCredentialHelperMode, runCredentialHelper } from "@executablemd/git/credential-helper";
2121
import type { HelperAssembly } from "@executablemd/git/credential-helper";
2222
import { useCompiledService } from "./compiled-service.ts";
@@ -94,6 +94,9 @@ if (isCredentialHelperMode(process.argv.slice(2))) {
9494
() => useDenoWorkflowHost(HELPER),
9595
useMachineSessions(),
9696
useCompiledRepl,
97+
// The same retained host the source entrypoint supplies: a compiled
98+
// binary is the same program, and an entry it runs resumes the same way.
99+
denoReplEntryHost(HELPER),
97100
);
98101
});
99102
}

‎packages/cli/src/deno-repositories.ts‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,16 @@
1515
*/
1616

1717
import type { Operation } from "effection";
18-
import { cwd } from "@executablemd/runtime";
19-
import { useRunComposition } from "@executablemd/git/deno";
18+
import { cwd, hostFilesHandler } from "@executablemd/runtime";
19+
import {
20+
denoRepositoryHost,
21+
useRetainedRepositoryEntry,
22+
useRunComposition,
23+
} from "@executablemd/git/deno";
2024
import type { HelperAssembly } from "@executablemd/git/credential-helper";
2125
import { DEFAULT_REPOSITORY_ROOT } from "./run-repositories.ts";
2226
import type { RepositoryInstaller } from "./run-repositories.ts";
27+
import type { ReplEntryHost, ReplEntryHostFactory } from "./repl-repositories.ts";
2328

2429
/**
2530
* The live provider Deno and the compiled binary install.
@@ -47,3 +52,42 @@ export function denoRunRepositories(
4752
});
4853
};
4954
}
55+
56+
/**
57+
* The retained host one REPL entry runs its repository work through.
58+
*
59+
* Built per entry, inside that entry's own task, which is what the factory is
60+
* for: the owner it retains under is this execution and this admitted entry,
61+
* and everything it acquires — a lease, a credential session, a scratch
62+
* directory — belongs to the task that asked for it and ends with it. A cold
63+
* view calls none of this, because nothing executes there to ask.
64+
*
65+
* Managed checkouts live under the same root an ordinary run uses, for the same
66+
* reason: what is in one is somebody's work, and which entry wrote it does not
67+
* change where it lives.
68+
*/
69+
export function denoReplEntryHost(
70+
helper: HelperAssembly,
71+
root: string = DEFAULT_REPOSITORY_ROOT,
72+
): ReplEntryHostFactory {
73+
return function* (request): Operation<ReplEntryHost> {
74+
const entry = yield* useRetainedRepositoryEntry({
75+
executionId: request.executionId,
76+
entryKey: request.entryKey,
77+
source: request.source,
78+
// Where this entry started, which is where its ambient checkout is
79+
// looked for. Read through the contextual Api rather than from the
80+
// process, and read once: a resume takes the root its own history
81+
// recorded instead of looking again.
82+
cwd: yield* cwd(),
83+
managedRoot: root,
84+
host: denoRepositoryHost({ helper }),
85+
// Ordinary file behavior, for everything outside a selected checkout. The
86+
// entry's own handlers perform it directly rather than routing back
87+
// through the public chain, which is what keeps a write beside the
88+
// document ordinary and a write inside a checkout retained.
89+
files: hostFilesHandler(),
90+
});
91+
return { installation: entry.installation, files: entry.files };
92+
};
93+
}

‎packages/cli/src/deno.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { importPluginModule } from "./host-plugin-modules.ts";
1919
import type { UpgradeAssembly } from "./upgrade.ts";
2020
import { useMachineSessions } from "./session-coordinator.ts";
2121
import { useDenoWorkflowHost } from "./deno-workflow.ts";
22-
import { denoRunRepositories } from "./deno-repositories.ts";
22+
import { denoReplEntryHost, denoRunRepositories } from "./deno-repositories.ts";
2323
import { isCredentialHelperMode, runCredentialHelper } from "@executablemd/git/credential-helper";
2424
import type { HelperAssembly } from "@executablemd/git/credential-helper";
2525
import { useDenoService } from "./deno-service.ts";
@@ -113,6 +113,11 @@ if (isCredentialHelperMode(process.argv.slice(2))) {
113113
() => useDenoWorkflowHost(HELPER),
114114
useMachineSessions(),
115115
useDenoRepl,
116+
// One retained repository host per REPL entry, under the same managed
117+
// root and the same credential-helper assembly the ordinary provider
118+
// uses. A factory, so nothing is built until an entry is actually
119+
// executing.
120+
denoReplEntryHost(HELPER),
116121
);
117122
});
118123
}

‎packages/cli/src/evaluation-profile.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,37 @@ export function ordinaryEvaluationProfile(): FragmentEvaluationInput {
103103
};
104104
}
105105

106+
/**
107+
* The same installations, with one entry's own file access bound into the
108+
* ceiling.
109+
*
110+
* The tables are untouched: what a generated fragment may name, and in which
111+
* spelling, is the command's single decision and is settled before any entry
112+
* exists. What changes per entry is only *which* instance the admitted
113+
* operations reach — this entry's retained one, so a fragment's write inside a
114+
* checkout the entry has selected is retained exactly as an authored write is.
115+
*
116+
* Rebound rather than appended, because one execution offers one maximum
117+
* permission: a second profile beside the first would be two ceilings for one
118+
* fragment, and "whichever came last wins" is how permission stops being
119+
* auditable.
120+
*/
121+
export function withEntryFileAccess(
122+
installations: readonly ExecutionInstallation[],
123+
files: FragmentFileAccess,
124+
): readonly ExecutionInstallation[] {
125+
return Object.freeze(
126+
installations.map((installation) =>
127+
installation.evaluation === undefined
128+
? installation
129+
: Object.freeze({
130+
...installation,
131+
evaluation: Object.freeze({ ...installation.evaluation, files }),
132+
}),
133+
),
134+
);
135+
}
136+
106137
/**
107138
* Whether the host that attached this execution already stated a ceiling.
108139
*

‎packages/cli/src/repl-profile.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { ordinaryEvaluationProfile } from "./evaluation-profile.ts";
2626
import { planComponentDeclaration } from "./plan-component.ts";
2727
import { planAgentContext } from "./plan-writer-profile.ts";
2828
import type { CommandPlugins } from "./plugin-host.ts";
29+
import type { ReplEntryHostFactory } from "./repl-repositories.ts";
2930

3031
/**
3132
* Where a REPL entry looks for components.
@@ -66,6 +67,16 @@ export interface ReplExecutionProfile {
6667
readonly installations: readonly ExecutionInstallation[];
6768
/** How this REPL answers an Agent permission request. */
6869
readonly permissionMode: PermissionMode;
70+
/**
71+
* How each entry of this execution obtains repository operations.
72+
*
73+
* A factory, retained and never called here: the host belongs to the
74+
* executing entry's own task, and building one at assembly would give every
75+
* entry of the session one owner and make a cold view somebody is only
76+
* reading acquire a checkout and a lease. Absent on a runtime that supplies
77+
* none, which leaves repository work refusing before anything is touched.
78+
*/
79+
readonly entryHost?: ReplEntryHostFactory;
6980
}
7081

7182
/**
@@ -85,6 +96,10 @@ export function* assembleReplProfile(
8596
stack: AgentStack,
8697
plugins: CommandPlugins,
8798
seams: ReplProfileSeams = {},
99+
// What this runtime offers an entry for repository work, from the entrypoint
100+
// that knows whether it has one. Retained as given: it is called by the entry
101+
// task, not here.
102+
entryHost?: ReplEntryHostFactory,
88103
): Operation<ReplExecutionProfile> {
89104
yield* installAgentProviderStack(stack, seams.acp);
90105

@@ -119,6 +134,7 @@ export function* assembleReplProfile(
119134
}),
120135
]),
121136
permissionMode: stack.permissionMode,
137+
...(entryHost === undefined ? {} : { entryHost }),
122138
});
123139
}
124140

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* How a REPL entry obtains repository operations, or does not.
3+
*
4+
* `run-repositories.ts` beside this one answers the same question for `xmd
5+
* run`, and the two differ in exactly one way that matters here: an ordinary
6+
* run's provider is installed per execution and remembers nothing, while an
7+
* entry's is installed per *entry* and retains what that entry did, so an
8+
* entry interrupted partway through repository work can be reopened and
9+
* continue instead of repeating it.
10+
*
11+
* What this module holds is the contract and nothing else. A factory rather
12+
* than a value, for the reason the ordinary installer is one and then some: the
13+
* host belongs to the executing entry's own task, holds that entry's owner,
14+
* selections, leases, credentials, lanes and publication permission, and ends
15+
* with it. Constructing one while the command is still assembling itself would
16+
* give every entry of the session the same owner and make a passive view — a
17+
* cold history somebody is only reading — acquire a lease and a checkout.
18+
*
19+
* Nothing here names a runtime. The shared command path cannot import the Deno
20+
* adapter at all: its module graph reaches `node:sqlite`, which Bun does not
21+
* have, so a static import would stop `xmd` loading there rather than refusing
22+
* a repository operation. The implementation lives beside the entrypoints that
23+
* can use it, in `deno-repositories.ts`, and a host that supplies none leaves
24+
* its entries refusing repository work before a lock, a credential, a
25+
* subprocess or a request exists.
26+
*/
27+
28+
import type { Operation } from "effection";
29+
import type { ExecutionInstallation, FragmentFileAccess } from "@executablemd/core/host";
30+
31+
/** What one entry tells its host about itself. */
32+
export interface ReplEntryHostRequest {
33+
/**
34+
* The execution this entry belongs to, as the host established it.
35+
*
36+
* Opaque, and the first half of the owner every record this entry writes is
37+
* retained under. It is the identifier the history file is named by, so a
38+
* location reopens the entries of that execution and of no other.
39+
*/
40+
readonly executionId: string;
41+
/**
42+
* This entry's own key, in the order the entries were admitted.
43+
*
44+
* Immutable once admitted, which is what makes the owner stable across
45+
* opens: the same entry of the same execution is the same owner however many
46+
* times it is reopened, and the entry after it is a different one however
47+
* identical its text.
48+
*/
49+
readonly entryKey: string;
50+
/** The canonical identity of the source this entry is running. */
51+
readonly source: string;
52+
}
53+
54+
/** What an entry's repository host offers the task that built it. */
55+
export interface ReplEntryHost {
56+
/**
57+
* What this entry declares to its own execution.
58+
*
59+
* Its journal admission, its canonical file-mutation handlers and the
60+
* repository components it answers for — installed into that one execution
61+
* and nowhere else.
62+
*/
63+
readonly installation: ExecutionInstallation;
64+
/**
65+
* The one captured file access this entry's generated fragments run on.
66+
*
67+
* Bound into the command's unchanged generated ceiling, so a fragment's write
68+
* inside a checkout this entry has selected is retained exactly as an
69+
* authored one is — and a fragment reaches it rather than `API.Files`, which
70+
* is what keeps a document, a repository component or middleware out from
71+
* between them.
72+
*/
73+
readonly files: FragmentFileAccess;
74+
}
75+
76+
/**
77+
* How one entry's repository host is built, when this runtime has one.
78+
*
79+
* Called by the sequential entry task, once per entry, with the trusted
80+
* execution identity and the admission-order key. Everything it acquires
81+
* belongs to that task.
82+
*/
83+
export type ReplEntryHostFactory = (request: ReplEntryHostRequest) => Operation<ReplEntryHost>;

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ export function* runReplProgram(options: ReplProgramOptions): Operation<Result<R
207207
includes: options.profile.includes,
208208
installations: options.profile.installations,
209209
permissionMode: options.profile.permissionMode,
210+
// Carried, not called: the session hands it to each entry's own task.
211+
...(options.profile.entryHost === undefined ? {} : { entryHost: options.profile.entryHost }),
210212
...(state.route.at === undefined ? {} : { selection: state.route.at }),
211213
});
212214

@@ -739,6 +741,15 @@ function* drive(
739741
} else if (built !== undefined && built.ok) {
740742
state = adopting;
741743
reading = candidate;
744+
// Standing at the head means the unfinished entry below is this
745+
// process's to continue. Said here rather than at the open, because a
746+
// location may name a frozen position: that view continues nothing, and
747+
// the control that comes back to the head is what asks for the work.
748+
// Idempotent, so a frame that merely redrew the head starts nothing and
749+
// an entry already running is left exactly as it is.
750+
if (state.route.at === undefined) {
751+
yield* current.resume();
752+
}
742753
// The readiness may have moved while that refusal was on the screen: the
743754
// entry it named has finished, or the teardown it named has completed.
744755
// A refusal that is no longer true is worse than no refusal, because it

0 commit comments

Comments
 (0)