Forge
csharp3407750f
1using AgentForge.Abstractions;
2using AgentForge.Configuration;
3using AgentForge.Data;
4using AgentForge.Options;
5using AgentForge.Services;
6
7namespace AgentForge.Plugins;
8
9public sealed class ForgePluginRuntime : IForgeViewContributorCatalog, IForgeRepoTabBodyCatalog, IForgeOAuthLoginContributorCatalog
10{
11 private readonly object _gate = new();
12 private ForgePluginSnapshot _snapshot;
13 private List<ForgePluginLoadContext> _loadContexts = [];
14
15 private ForgePluginRuntime(ForgePluginSnapshot snapshot, List<ForgePluginLoadContext> loadContexts)
16 {
17 _snapshot = snapshot;
18 _loadContexts = loadContexts;
19 }
20
21 public ForgePluginRegistry Registry
22 {
23 get
24 {
25 lock (_gate)
26 return _snapshot.Registry;
27 }
28 }
29
30 public ForgeMcpPluginToolDispatch McpDispatch
31 {
32 get
33 {
34 lock (_gate)
35 return _snapshot.McpDispatch;
36 }
37 }
38
39 public ForgeCommandCatalog CommandCatalog
40 {
41 get
42 {
43 lock (_gate)
44 return _snapshot.CommandCatalog;
45 }
46 }
47
48 public IReadOnlyList<ForgeViewContributor> Contributors
49 {
50 get
51 {
52 lock (_gate)
53 return _snapshot.ViewContributors.Contributors;
54 }
55 }
56
57 public IReadOnlyList<ForgeOAuthLoginContributor> OAuthLoginContributors
58 {
59 get
60 {
61 lock (_gate)
62 return _snapshot.OAuthLoginContributors.Contributors;
63 }
64 }
65
66 IReadOnlyList<ForgeOAuthLoginContributor> IForgeOAuthLoginContributorCatalog.Contributors => OAuthLoginContributors;
67
68 public bool HasTab(string tabId)
69 {
70 lock (_gate)
71 return _snapshot.ViewContributors.HasTab(tabId);
72 }
73
74 public bool TryRender(string tabId, ForgeRepoTabBodyContext context, out string html)
75 {
76 lock (_gate)
77 return _snapshot.RepoTabBodies.TryRender(tabId, context, out html);
78 }
79
80 public IReadOnlyList<ForgePluginStatusEntry> DescribePlugins()
81 {
82 lock (_gate)
83 {
84 return _loadContexts
85 .Select(ctx => new ForgePluginStatusEntry(ctx.PluginId, ctx.AssemblyPath, Loaded: true))
86 .ToList();
87 }
88 }
89
90 public static ForgePluginRuntime Load(WebApplicationBuilder builder)
91 {
92 var bundle = LoadBundle(
93 builder.Environment.ContentRootPath,
94 builder.Configuration,
95 builder.Configuration.GetSection(ForgeOptions.SectionName).Get<ForgeOptions>() ?? new ForgeOptions());
96 ForgePluginLoader.ConfigureServices(bundle.Snapshot.Registry.Plugins, builder.Services, builder.Configuration);
97 return new ForgePluginRuntime(bundle.Snapshot, bundle.LoadContexts);
98 }
99
100 internal ForgePluginHotReloadResult TryHotReload(
101 string contentRoot,
102 IConfiguration configuration,
103 ForgeOptions options,
104 ForgeDbContext db)
105 {
106 ForgeEnvironment.ApplyEnvironmentOverrides(options);
107 var bundle = LoadBundle(contentRoot, configuration, options);
108
109 lock (_gate)
110 {
111 var previousContexts = _loadContexts;
112 _snapshot = bundle.Snapshot;
113 _loadContexts = bundle.LoadContexts;
114
115 var migrationContext = new ForgeMigrationContext(db);
116 ForgePluginLoader.ApplyMigrations(_snapshot.Registry.Plugins, migrationContext);
117
118 UnloadContexts(previousContexts);
119 }
120
121 return new ForgePluginHotReloadResult(
122 true,
123 _snapshot.Registry.ActiveBundle,
124 _snapshot.Registry.Ids,
125 EndpointsStale: true);
126 }
127
128 private static ForgePluginLoadBundle LoadBundle(string contentRoot, IConfiguration configuration, ForgeOptions options)
129 {
130 ForgeEnvironment.ApplyEnvironmentOverrides(options);
131 var manifest = ForgePluginLoader.LoadManifest(contentRoot, configuration);
132 if (ForgePluginLoader.ResolveDiscovery(manifest) == ForgePluginDiscovery.Auto)
133 return LoadAutoDiscoveredBundle(manifest, options);
134
135 var bundleName = !string.IsNullOrWhiteSpace(options.PluginBundle)
136 ? options.PluginBundle.Trim()
137 : !string.IsNullOrWhiteSpace(manifest.ActiveBundle)
138 ? manifest.ActiveBundle.Trim()
139 : "standard";
140
141 if (!manifest.Bundles.TryGetValue(bundleName, out var pluginIds) || pluginIds.Length == 0)
142 {
143 var emptyRegistry = new ForgePluginRegistry { ActiveBundle = bundleName, Discovery = ForgePluginLoader.ResolveDiscovery(manifest) };
144 var emptySnapshot = BuildSnapshot(emptyRegistry);
145 return new ForgePluginLoadBundle(emptySnapshot, []);
146 }
147
148 var plugins = new List<IForgePlugin>();
149 var contexts = new List<ForgePluginLoadContext>();
150 var entries = new Dictionary<string, ForgePluginEntry>(StringComparer.OrdinalIgnoreCase);
151 foreach (var id in pluginIds)
152 {
153 if (string.IsNullOrWhiteSpace(id))
154 continue;
155
156 if (!manifest.Plugins.TryGetValue(id, out var entry))
157 throw new InvalidOperationException($"Plugin '{id}' is listed in bundle '{bundleName}' but missing from manifest plugins map.");
158
159 entries[id.Trim()] = entry;
160 var (plugin, context) = ForgePluginLoader.LoadPluginWithContext(id, entry, options.PluginsPath);
161 plugins.Add(plugin);
162 if (context is not null)
163 contexts.Add(context);
164 }
165
166 var registry = new ForgePluginRegistry
167 {
168 ActiveBundle = bundleName,
169 Discovery = ForgePluginLoader.ResolveDiscovery(manifest),
170 Plugins = plugins,
171 PluginEntries = entries,
172 };
173 return new ForgePluginLoadBundle(BuildSnapshot(registry), contexts);
174 }
175
176 private static ForgePluginLoadBundle LoadAutoDiscoveredBundle(ForgePluginManifest manifest, ForgeOptions options)
177 {
178 var discovered = ForgePluginLoader.DiscoverPlugins(manifest, options.PluginsPath);
179 var plugins = new List<IForgePlugin>();
180 var contexts = new List<ForgePluginLoadContext>();
181 var entries = new Dictionary<string, ForgePluginEntry>(StringComparer.OrdinalIgnoreCase);
182 foreach (var item in discovered)
183 {
184 plugins.Add(item.Plugin);
185 if (item.Context is not null)
186 contexts.Add(item.Context);
187 entries[item.Id] = item.Entry;
188 }
189
190 var registry = new ForgePluginRegistry
191 {
192 ActiveBundle = "auto",
193 Discovery = ForgePluginDiscovery.Auto,
194 Plugins = plugins,
195 PluginEntries = entries,
196 };
197 return new ForgePluginLoadBundle(BuildSnapshot(registry), contexts);
198 }
199
200 private static ForgePluginSnapshot BuildSnapshot(ForgePluginRegistry registry) =>
201 new(
202 registry,
203 ForgeMcpPluginToolDispatch.Build(registry.Plugins),
204 ForgeCommandCatalog.Build(registry.Plugins, registry.PluginEntries),
205 ForgeViewContributorCatalog.Build(registry.Plugins),
206 ForgeRepoTabBodyCatalog.Build(registry.Plugins),
207 ForgeOAuthLoginContributorCatalog.Build(registry.Plugins));
208
209 private static void UnloadContexts(List<ForgePluginLoadContext> contexts)
210 {
211 foreach (var context in contexts)
212 context.Unload();
213
214 GC.Collect();
215 GC.WaitForPendingFinalizers();
216 GC.Collect();
217 }
218
219 private sealed record ForgePluginLoadBundle(ForgePluginSnapshot Snapshot, List<ForgePluginLoadContext> LoadContexts);
220
221 private sealed record ForgePluginSnapshot(
222 ForgePluginRegistry Registry,
223 ForgeMcpPluginToolDispatch McpDispatch,
224 ForgeCommandCatalog CommandCatalog,
225 ForgeViewContributorCatalog ViewContributors,
226 ForgeRepoTabBodyCatalog RepoTabBodies,
227 ForgeOAuthLoginContributorCatalogSnapshot OAuthLoginContributors);
228}
229
230public sealed record ForgePluginStatusEntry(string PluginId, string AssemblyPath, bool Loaded);
231
232public sealed record ForgePluginHotReloadResult(
233 bool Success,
234 string Bundle,
235 IReadOnlyList<string> Plugins,
236 bool EndpointsStale);
237
View only · write via MCP/CIDE