feat: 按 Extension ID 装载初始化配置
This commit is contained in:
parent
f728132c81
commit
59903d6d5e
@ -17,6 +17,8 @@ pnpm dev
|
||||
|
||||
CLI 支持 `/new` 新建对话、`/switch <id>` 切换对话、`/history` 查看当前对话、`/exit` 退出;输入 `//` 可以发送以 `/` 开头的普通消息。
|
||||
|
||||
Runtime 会读取 `<LLM_TO_AGENT_HOME>/config/extensions.json` 和当前 Workspace 下同结构的 `config/extensions.json`;文件缺失时继续使用默认值,不会自动创建。
|
||||
|
||||
文档:
|
||||
|
||||
- [架构总览](docs/architecture.md)
|
||||
|
||||
@ -39,6 +39,18 @@ Extension 是 Runtime 的独立装配单位,分为三类:
|
||||
|
||||
最终能力归属见 [Extension 规划](extensions.md)。
|
||||
|
||||
## 配置
|
||||
|
||||
Runtime 启动时读取全局和当前 Workspace 的可选配置,Workspace 配置按字段覆盖全局配置。产品装配向 Kernel 提供 Extension 工厂函数,每个工厂函数用自身的静态 `id` 表明身份:
|
||||
|
||||
```ts
|
||||
createDeepSeekExtension.id = ExtensionId.DeepSeek;
|
||||
```
|
||||
|
||||
`kernel.use(createDeepSeekExtension)` 根据这个 `id` 取得对应配置,调用 `createDeepSeekExtension(options)`,再保存创建出的 Extension 实例。配置文件位置、作用域和合并规则都不进入 Kernel。
|
||||
|
||||
Extension 负责解释和校验自己的片段。第一版配置只在启动时读取,不创建缺失文件、不写入默认值,也不支持热更新。
|
||||
|
||||
## 扩展点
|
||||
|
||||
多个实现向能力所有者登记,不再为扩展点增加新的架构层:
|
||||
|
||||
@ -15,9 +15,12 @@
|
||||
```text
|
||||
<LLM_TO_AGENT_HOME>/
|
||||
├── config/
|
||||
│ └── extensions.json
|
||||
├── workspaces/
|
||||
│ └── <workspace-id>/
|
||||
│ ├── workspace.json
|
||||
│ ├── config/
|
||||
│ │ └── extensions.json
|
||||
│ ├── files/
|
||||
│ ├── conversations/
|
||||
│ ├── artifacts/
|
||||
@ -32,6 +35,26 @@
|
||||
|
||||
目录按需要创建,不预建尚未使用的层级。
|
||||
|
||||
## Extension 配置
|
||||
|
||||
全局和 Workspace 配置使用相同结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"extensions": {
|
||||
"models": {
|
||||
"defaultProvider": "deepseek"
|
||||
},
|
||||
"deepseek": {
|
||||
"model": "deepseek-v4-flash"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Runtime 先读取全局配置,再使用 Workspace 中同 Extension、同字段的值覆盖它。两个文件都是可选的;第一版只读,不自动创建、补全或修改配置文件。
|
||||
|
||||
## 数据归属
|
||||
|
||||
- `workspace` 管理 Workspace、Conversation、Message 和 Artifact;
|
||||
|
||||
@ -13,6 +13,7 @@ llm-to-agent/
|
||||
│ │ ├── web/
|
||||
│ │ └── desktop/
|
||||
│ ├── products/
|
||||
│ ├── config.ts
|
||||
│ └── main.ts
|
||||
├── docs/
|
||||
├── tests/
|
||||
@ -28,6 +29,7 @@ llm-to-agent/
|
||||
- `extensions/shared/`:跨产品使用的能力和 Provider;
|
||||
- `extensions/cli|web|desktop/`:产品输入、展示和平台集成;
|
||||
- `products/`:静态选择产品启用的 Extension,不写业务逻辑;
|
||||
- `config.ts`:读取并合并全局与 Workspace Extension 配置;
|
||||
- `tests/`:跨 Extension、跨进程或跨版本测试;
|
||||
- `tooling/`:构建、开发和发布辅助。
|
||||
|
||||
|
||||
79
src/config.test.ts
Normal file
79
src/config.test.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { loadRuntimeConfig, workspaceIdFor } from "./config";
|
||||
|
||||
test("merges global and workspace extension configs", async (t) => {
|
||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-config-"));
|
||||
const home = join(temporaryDirectory, "home");
|
||||
const projectPath = join(temporaryDirectory, "project");
|
||||
const workspaceId = workspaceIdFor(projectPath);
|
||||
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
|
||||
|
||||
await mkdir(join(home, "config"), { recursive: true });
|
||||
await mkdir(join(home, "workspaces", workspaceId, "config"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(
|
||||
join(home, "config", "extensions.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
extensions: {
|
||||
models: { defaultProvider: "deepseek" },
|
||||
shell: { timeoutMs: 30_000, maxOutputLength: 50_000 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
join(home, "workspaces", workspaceId, "config", "extensions.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
extensions: {
|
||||
shell: { timeoutMs: 120_000 },
|
||||
project: { checks: ["pnpm test"] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const config = await loadRuntimeConfig({ home, projectPath });
|
||||
|
||||
assert.equal(config.workspaceId, workspaceId);
|
||||
assert.deepEqual(config.extensions, {
|
||||
models: { defaultProvider: "deepseek" },
|
||||
shell: { timeoutMs: 120_000, maxOutputLength: 50_000 },
|
||||
project: { checks: ["pnpm test"] },
|
||||
});
|
||||
});
|
||||
|
||||
test("does not create missing config files", async (t) => {
|
||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-config-"));
|
||||
const home = join(temporaryDirectory, "home");
|
||||
const projectPath = join(temporaryDirectory, "project");
|
||||
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
|
||||
|
||||
const config = await loadRuntimeConfig({ home, projectPath });
|
||||
|
||||
assert.deepEqual(config.extensions, {});
|
||||
assert.deepEqual(await readdir(temporaryDirectory), []);
|
||||
});
|
||||
|
||||
test("rejects invalid extension config sections", async (t) => {
|
||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-config-"));
|
||||
const home = join(temporaryDirectory, "home");
|
||||
const projectPath = join(temporaryDirectory, "project");
|
||||
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
|
||||
|
||||
await mkdir(join(home, "config"), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, "config", "extensions.json"),
|
||||
JSON.stringify({ version: 1, extensions: { models: "deepseek" } }),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
loadRuntimeConfig({ home, projectPath }),
|
||||
/invalid "models" section/,
|
||||
);
|
||||
});
|
||||
104
src/config.ts
Normal file
104
src/config.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import type { ExtensionConfig } from "./kernel";
|
||||
|
||||
export type ExtensionConfigs = Record<string, ExtensionConfig>;
|
||||
|
||||
export interface RuntimeConfigOptions {
|
||||
home?: string;
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
home: string;
|
||||
projectPath: string;
|
||||
workspaceId: string;
|
||||
extensions: ExtensionConfigs;
|
||||
}
|
||||
|
||||
export function workspaceIdFor(projectPath: string): string {
|
||||
return createHash("sha256").update(resolve(projectPath)).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export async function loadRuntimeConfig(
|
||||
options: RuntimeConfigOptions = {},
|
||||
): Promise<RuntimeConfig> {
|
||||
const home = resolve(
|
||||
options.home ?? process.env.LLM_TO_AGENT_HOME ?? join(homedir(), ".llm-to-agent"),
|
||||
);
|
||||
const projectPath = resolve(options.projectPath ?? process.cwd());
|
||||
const workspaceId = workspaceIdFor(projectPath);
|
||||
const globalFile = join(home, "config", "extensions.json");
|
||||
const workspaceFile = join(
|
||||
home,
|
||||
"workspaces",
|
||||
workspaceId,
|
||||
"config",
|
||||
"extensions.json",
|
||||
);
|
||||
|
||||
async function readExtensions(file: string): Promise<ExtensionConfigs> {
|
||||
let content: string;
|
||||
|
||||
try {
|
||||
content = await readFile(file, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
||||
throw error;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON in extension config "${file}".`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`Extension config "${file}" must be an object.`);
|
||||
}
|
||||
|
||||
const document = parsed as Record<string, unknown>;
|
||||
if (document.version !== 1) {
|
||||
throw new Error(`Extension config "${file}" must use version 1.`);
|
||||
}
|
||||
if (
|
||||
!document.extensions ||
|
||||
typeof document.extensions !== "object" ||
|
||||
Array.isArray(document.extensions)
|
||||
) {
|
||||
throw new Error(`Extension config "${file}" must contain extensions.`);
|
||||
}
|
||||
|
||||
const extensions: ExtensionConfigs = {};
|
||||
for (const [id, config] of Object.entries(document.extensions)) {
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
throw new Error(
|
||||
`Extension config "${file}" contains an invalid "${id}" section.`,
|
||||
);
|
||||
}
|
||||
extensions[id] = config as ExtensionConfig;
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
|
||||
const globalExtensions = await readExtensions(globalFile);
|
||||
const workspaceExtensions = await readExtensions(workspaceFile);
|
||||
const extensions: ExtensionConfigs = {};
|
||||
|
||||
for (const [id, config] of Object.entries(globalExtensions)) {
|
||||
extensions[id] = { ...config };
|
||||
}
|
||||
for (const [id, config] of Object.entries(workspaceExtensions)) {
|
||||
extensions[id] = { ...extensions[id], ...config };
|
||||
}
|
||||
|
||||
return { home, projectPath, workspaceId, extensions };
|
||||
}
|
||||
@ -5,7 +5,7 @@ import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { Kernel } from "../kernel";
|
||||
import { Hook } from "./catalog";
|
||||
import { ExtensionId, Hook } from "./catalog";
|
||||
import {
|
||||
createAgentExtension,
|
||||
type AgentService,
|
||||
@ -51,10 +51,14 @@ test("streams a reply, saves it, and restores the conversation after restart", a
|
||||
});
|
||||
};
|
||||
|
||||
const firstKernel = new Kernel().use(
|
||||
createWorkspaceExtension({ home, projectPath }),
|
||||
createDeepSeekExtension({ apiKey: "test-key", request }),
|
||||
createAgentExtension(),
|
||||
const extensionConfigs = {
|
||||
[ExtensionId.Workspace]: { home, projectPath },
|
||||
[ExtensionId.DeepSeek]: { apiKey: "test-key", request },
|
||||
};
|
||||
const firstKernel = new Kernel({ extensionConfigs }).use(
|
||||
createWorkspaceExtension,
|
||||
createDeepSeekExtension,
|
||||
createAgentExtension,
|
||||
);
|
||||
|
||||
await firstKernel.start();
|
||||
@ -67,10 +71,10 @@ test("streams a reply, saves it, and restores the conversation after restart", a
|
||||
assert.equal(firstAnswer, "第一次回答");
|
||||
await firstKernel.stop();
|
||||
|
||||
const secondKernel = new Kernel().use(
|
||||
createWorkspaceExtension({ home, projectPath }),
|
||||
createDeepSeekExtension({ apiKey: "test-key", request }),
|
||||
createAgentExtension(),
|
||||
const secondKernel = new Kernel({ extensionConfigs }).use(
|
||||
createWorkspaceExtension,
|
||||
createDeepSeekExtension,
|
||||
createAgentExtension,
|
||||
);
|
||||
|
||||
await secondKernel.start();
|
||||
@ -109,8 +113,11 @@ test("creates a conversation and restores it after restart", async (t) => {
|
||||
await mkdir(projectPath);
|
||||
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
|
||||
|
||||
const firstKernel = new Kernel().use(
|
||||
createWorkspaceExtension({ home, projectPath }),
|
||||
const extensionConfigs = {
|
||||
[ExtensionId.Workspace]: { home, projectPath },
|
||||
};
|
||||
const firstKernel = new Kernel({ extensionConfigs }).use(
|
||||
createWorkspaceExtension,
|
||||
);
|
||||
await firstKernel.start();
|
||||
|
||||
@ -129,8 +136,8 @@ test("creates a conversation and restores it after restart", async (t) => {
|
||||
assert.equal(await firstWorkspace.switchConversation(conversationId), true);
|
||||
await firstKernel.stop();
|
||||
|
||||
const secondKernel = new Kernel().use(
|
||||
createWorkspaceExtension({ home, projectPath }),
|
||||
const secondKernel = new Kernel({ extensionConfigs }).use(
|
||||
createWorkspaceExtension,
|
||||
);
|
||||
await secondKernel.start();
|
||||
|
||||
|
||||
@ -15,8 +15,6 @@ export function createCliExtension(): Extension {
|
||||
let currentRequest: AbortController | undefined;
|
||||
|
||||
return {
|
||||
id: ExtensionId.Cli,
|
||||
|
||||
setup() {},
|
||||
|
||||
start(context) {
|
||||
@ -125,3 +123,5 @@ export function createCliExtension(): Extension {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createCliExtension.id = ExtensionId.Cli;
|
||||
|
||||
@ -68,8 +68,6 @@ export function createAgentExtension(): Extension {
|
||||
};
|
||||
|
||||
return {
|
||||
id: ExtensionId.Agent,
|
||||
|
||||
setup(context) {
|
||||
context.add(Hook.Agent, agent);
|
||||
},
|
||||
@ -93,3 +91,5 @@ export function createAgentExtension(): Extension {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createAgentExtension.id = ExtensionId.Agent;
|
||||
|
||||
60
src/extensions/shared/deepseek/index.test.ts
Normal file
60
src/extensions/shared/deepseek/index.test.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { Kernel } from "../../../kernel";
|
||||
import { ExtensionId, Hook } from "../../catalog";
|
||||
import type { ModelProvider } from "../agent";
|
||||
import { createDeepSeekExtension } from ".";
|
||||
|
||||
test("reads DeepSeek settings from extension config", async (t) => {
|
||||
const environment = {
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
|
||||
DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL,
|
||||
DEEPSEEK_MODEL: process.env.DEEPSEEK_MODEL,
|
||||
};
|
||||
delete process.env.DEEPSEEK_API_KEY;
|
||||
delete process.env.DEEPSEEK_BASE_URL;
|
||||
delete process.env.DEEPSEEK_MODEL;
|
||||
t.after(() => {
|
||||
for (const [name, value] of Object.entries(environment)) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
});
|
||||
|
||||
let requestUrl = "";
|
||||
let requestInit: RequestInit | undefined;
|
||||
const request = async (url: string, init: RequestInit) => {
|
||||
requestUrl = url;
|
||||
requestInit = init;
|
||||
return new Response("data: [DONE]\n\n");
|
||||
};
|
||||
const kernel = new Kernel({
|
||||
extensionConfigs: {
|
||||
[ExtensionId.DeepSeek]: {
|
||||
apiKey: "config-key",
|
||||
baseUrl: "https://example.test/v1/",
|
||||
model: "config-model",
|
||||
request,
|
||||
},
|
||||
},
|
||||
}).use(createDeepSeekExtension);
|
||||
await kernel.start();
|
||||
|
||||
const provider = kernel.all<ModelProvider>(Hook.ModelProviders)[0];
|
||||
assert.ok(provider);
|
||||
|
||||
for await (const _ of provider.chat([
|
||||
{ role: "user", content: "你好", createdAt: "2026-08-04T00:00:00.000Z" },
|
||||
])) {
|
||||
// The test response contains no text chunks.
|
||||
}
|
||||
|
||||
assert.equal(requestUrl, "https://example.test/v1/chat/completions");
|
||||
assert.equal(
|
||||
(requestInit?.headers as Record<string, string>).authorization,
|
||||
"Bearer config-key",
|
||||
);
|
||||
assert.equal(JSON.parse(String(requestInit?.body)).model, "config-model");
|
||||
await kernel.stop();
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Extension } from "../../../kernel";
|
||||
import type { Extension, ExtensionConfig } from "../../../kernel";
|
||||
import { ExtensionId, Hook } from "../../catalog";
|
||||
import type { ModelProvider } from "../agent";
|
||||
|
||||
@ -9,15 +9,30 @@ export interface DeepSeekOptions {
|
||||
request?: (url: string, init: RequestInit) => Promise<Response>;
|
||||
}
|
||||
|
||||
export function createDeepSeekExtension(options: DeepSeekOptions = {}): Extension {
|
||||
const apiKey = options.apiKey ?? process.env.DEEPSEEK_API_KEY;
|
||||
export function createDeepSeekExtension(options: ExtensionConfig = {}): Extension {
|
||||
if (options.apiKey !== undefined && typeof options.apiKey !== "string") {
|
||||
throw new Error("deepseek.apiKey must be a string.");
|
||||
}
|
||||
if (options.baseUrl !== undefined && typeof options.baseUrl !== "string") {
|
||||
throw new Error("deepseek.baseUrl must be a string.");
|
||||
}
|
||||
if (options.model !== undefined && typeof options.model !== "string") {
|
||||
throw new Error("deepseek.model must be a string.");
|
||||
}
|
||||
if (options.request !== undefined && typeof options.request !== "function") {
|
||||
throw new Error("deepseek.request must be a function.");
|
||||
}
|
||||
|
||||
const config = options as DeepSeekOptions;
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY ?? config.apiKey;
|
||||
const baseUrl = (
|
||||
options.baseUrl ??
|
||||
process.env.DEEPSEEK_BASE_URL ??
|
||||
config.baseUrl ??
|
||||
"https://api.deepseek.com"
|
||||
).replace(/\/+$/, "");
|
||||
const model = options.model ?? process.env.DEEPSEEK_MODEL ?? "deepseek-v4-flash";
|
||||
const request = options.request ?? fetch;
|
||||
const model =
|
||||
process.env.DEEPSEEK_MODEL ?? config.model ?? "deepseek-v4-flash";
|
||||
const request = config.request ?? fetch;
|
||||
|
||||
const provider: ModelProvider = {
|
||||
id: "deepseek",
|
||||
@ -83,8 +98,6 @@ export function createDeepSeekExtension(options: DeepSeekOptions = {}): Extensio
|
||||
};
|
||||
|
||||
return {
|
||||
id: ExtensionId.DeepSeek,
|
||||
|
||||
setup(context) {
|
||||
if (!apiKey) {
|
||||
throw new Error("DEEPSEEK_API_KEY is required.");
|
||||
@ -94,3 +107,5 @@ export function createDeepSeekExtension(options: DeepSeekOptions = {}): Extensio
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createDeepSeekExtension.id = ExtensionId.DeepSeek;
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import type { Extension } from "../../../kernel";
|
||||
import { workspaceIdFor } from "../../../config";
|
||||
import type { Extension, ExtensionConfig } from "../../../kernel";
|
||||
import { ExtensionId, Hook } from "../../catalog";
|
||||
|
||||
export type MessageRole = "system" | "user" | "assistant";
|
||||
@ -31,16 +32,33 @@ export interface WorkspaceOptions {
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extension {
|
||||
export function createWorkspaceExtension(options: ExtensionConfig = {}): Extension {
|
||||
if (options.home !== undefined && typeof options.home !== "string") {
|
||||
throw new Error("workspace.home must be a string.");
|
||||
}
|
||||
if (
|
||||
options.projectPath !== undefined &&
|
||||
typeof options.projectPath !== "string"
|
||||
) {
|
||||
throw new Error("workspace.projectPath must be a string.");
|
||||
}
|
||||
if (
|
||||
options.conversationId !== undefined &&
|
||||
typeof options.conversationId !== "string"
|
||||
) {
|
||||
throw new Error("workspace.conversationId must be a string.");
|
||||
}
|
||||
|
||||
const config = options as WorkspaceOptions;
|
||||
const home = resolve(
|
||||
options.home ?? process.env.LLM_TO_AGENT_HOME ?? join(homedir(), ".llm-to-agent"),
|
||||
config.home ?? process.env.LLM_TO_AGENT_HOME ?? join(homedir(), ".llm-to-agent"),
|
||||
);
|
||||
const projectPath = resolve(options.projectPath ?? process.cwd());
|
||||
const workspaceId = createHash("sha256").update(projectPath).digest("hex").slice(0, 16);
|
||||
const projectPath = resolve(config.projectPath ?? process.cwd());
|
||||
const workspaceId = workspaceIdFor(projectPath);
|
||||
const workspaceDirectory = join(home, "workspaces", workspaceId);
|
||||
const conversationsDirectory = join(workspaceDirectory, "conversations");
|
||||
const workspaceFile = join(workspaceDirectory, "workspace.json");
|
||||
let conversationId = options.conversationId ?? "default";
|
||||
let conversationId = config.conversationId ?? "default";
|
||||
|
||||
async function saveWorkspace() {
|
||||
await writeFile(
|
||||
@ -123,12 +141,10 @@ export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extens
|
||||
};
|
||||
|
||||
return {
|
||||
id: ExtensionId.Workspace,
|
||||
|
||||
async setup(context) {
|
||||
await mkdir(workspaceDirectory, { recursive: true });
|
||||
|
||||
if (!options.conversationId) {
|
||||
if (!config.conversationId) {
|
||||
try {
|
||||
const savedWorkspace = JSON.parse(await readFile(workspaceFile, "utf8"));
|
||||
if (savedWorkspace.activeConversationId) {
|
||||
@ -146,3 +162,5 @@ export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extens
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createWorkspaceExtension.id = ExtensionId.Workspace;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { EventHandler, Unsubscribe } from "./events";
|
||||
|
||||
export type Awaitable<T> = T | Promise<T>;
|
||||
export type ExtensionConfig = Record<string, unknown>;
|
||||
|
||||
export interface ExtensionSetupContext {
|
||||
add<T>(name: string, value: T): void;
|
||||
@ -15,10 +16,12 @@ export interface ExtensionRuntimeContext {
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
id: string;
|
||||
setup(context: ExtensionSetupContext): Awaitable<void>;
|
||||
start?(context: ExtensionRuntimeContext): Awaitable<void>;
|
||||
stop?(context: ExtensionRuntimeContext): Awaitable<void>;
|
||||
}
|
||||
|
||||
export type ExtensionFactory = () => Extension;
|
||||
export interface ExtensionFactory {
|
||||
id: string;
|
||||
(options?: ExtensionConfig): Extension;
|
||||
}
|
||||
|
||||
@ -1,13 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { EventBus, Kernel, type Extension } from "./index";
|
||||
import {
|
||||
EventBus,
|
||||
Kernel,
|
||||
type Extension,
|
||||
type ExtensionFactory,
|
||||
} from "./index";
|
||||
|
||||
function extensionFactory(
|
||||
id: string,
|
||||
createExtension: () => Extension,
|
||||
): ExtensionFactory {
|
||||
function create() {
|
||||
return createExtension();
|
||||
}
|
||||
create.id = id;
|
||||
return create;
|
||||
}
|
||||
|
||||
test("sets up every extension before starting them and stops in reverse order", async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const first: Extension = {
|
||||
id: "first",
|
||||
const first = extensionFactory("first", () => ({
|
||||
setup(context) {
|
||||
calls.push("first.setup");
|
||||
context.add("test.values", "first");
|
||||
@ -18,10 +33,9 @@ test("sets up every extension before starting them and stops in reverse order",
|
||||
stop() {
|
||||
calls.push("first.stop");
|
||||
},
|
||||
};
|
||||
}));
|
||||
|
||||
const second: Extension = {
|
||||
id: "second",
|
||||
const second = extensionFactory("second", () => ({
|
||||
setup(context) {
|
||||
calls.push("second.setup");
|
||||
context.add("test.values", "second");
|
||||
@ -32,7 +46,7 @@ test("sets up every extension before starting them and stops in reverse order",
|
||||
stop() {
|
||||
calls.push("second.stop");
|
||||
},
|
||||
};
|
||||
}));
|
||||
|
||||
const kernel = new Kernel().use(first, second);
|
||||
await kernel.start();
|
||||
@ -49,23 +63,53 @@ test("sets up every extension before starting them and stops in reverse order",
|
||||
});
|
||||
|
||||
test("rejects duplicate extension ids without partially installing a batch", () => {
|
||||
const first: Extension = { id: "first", setup() {} };
|
||||
const duplicate: Extension = { id: "first", setup() {} };
|
||||
const first = extensionFactory("first", () => ({ setup() {} }));
|
||||
const duplicate = extensionFactory("first", () => ({ setup() {} }));
|
||||
const kernel = new Kernel();
|
||||
|
||||
assert.throws(() => kernel.use(first, duplicate), /already installed/);
|
||||
assert.deepEqual(kernel.installedExtensionIds, []);
|
||||
});
|
||||
|
||||
test("passes extension config by id when constructing", async () => {
|
||||
const extensionConfigs = {
|
||||
first: { value: "configured" },
|
||||
second: { enabled: true },
|
||||
};
|
||||
const received: Record<string, unknown>[] = [];
|
||||
|
||||
function createFirst(options: Record<string, unknown> = {}) {
|
||||
received.push(options);
|
||||
return { setup() {} };
|
||||
}
|
||||
createFirst.id = "first";
|
||||
|
||||
function createUnconfigured(options: Record<string, unknown> = {}) {
|
||||
received.push(options);
|
||||
return { setup() {} };
|
||||
}
|
||||
createUnconfigured.id = "unconfigured";
|
||||
|
||||
const kernel = new Kernel({ extensionConfigs }).use(
|
||||
createFirst,
|
||||
createUnconfigured,
|
||||
);
|
||||
|
||||
extensionConfigs.first.value = "changed after use";
|
||||
await kernel.start();
|
||||
|
||||
assert.deepEqual(received, [{ value: "configured" }, {}]);
|
||||
await kernel.stop();
|
||||
});
|
||||
|
||||
test("gets one value or collects multiple values", async () => {
|
||||
const kernel = new Kernel().use({
|
||||
id: "values",
|
||||
const kernel = new Kernel().use(extensionFactory("values", () => ({
|
||||
setup(context) {
|
||||
context.add("single", "one");
|
||||
context.add("multiple", "one");
|
||||
context.add("multiple", "two");
|
||||
},
|
||||
});
|
||||
})));
|
||||
|
||||
await kernel.start();
|
||||
|
||||
@ -76,13 +120,12 @@ test("gets one value or collects multiple values", async () => {
|
||||
});
|
||||
|
||||
test("clears registrations when setup fails", async () => {
|
||||
const kernel = new Kernel().use({
|
||||
id: "broken-setup",
|
||||
const kernel = new Kernel().use(extensionFactory("broken-setup", () => ({
|
||||
setup(context) {
|
||||
context.add("temporary", "value");
|
||||
throw new Error("setup failed");
|
||||
},
|
||||
});
|
||||
})));
|
||||
|
||||
await assert.rejects(kernel.start(), /setup failed/);
|
||||
|
||||
@ -93,21 +136,19 @@ test("clears registrations when setup fails", async () => {
|
||||
test("stops every active extension after cleanup errors", async () => {
|
||||
const calls: string[] = [];
|
||||
const kernel = new Kernel().use(
|
||||
{
|
||||
id: "first",
|
||||
extensionFactory("first", () => ({
|
||||
setup() {},
|
||||
stop() {
|
||||
calls.push("first.stop");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "second",
|
||||
})),
|
||||
extensionFactory("second", () => ({
|
||||
setup() {},
|
||||
stop() {
|
||||
calls.push("second.stop");
|
||||
throw new Error("cleanup failed");
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
await kernel.start();
|
||||
|
||||
@ -1,23 +1,36 @@
|
||||
import { EventBus, type EventHandler, type Unsubscribe } from "./events";
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionConfig,
|
||||
ExtensionFactory,
|
||||
ExtensionRuntimeContext,
|
||||
ExtensionSetupContext,
|
||||
} from "./extension";
|
||||
import { ExtensionRegistry } from "./registry";
|
||||
|
||||
export interface KernelOptions {
|
||||
extensionConfigs?: Record<string, ExtensionConfig>;
|
||||
}
|
||||
|
||||
interface InstalledExtension {
|
||||
id: string;
|
||||
extension: Extension;
|
||||
}
|
||||
|
||||
export class Kernel {
|
||||
#extensions: Extension[] = [];
|
||||
#extensions: InstalledExtension[] = [];
|
||||
#activeExtensions: Extension[] = [];
|
||||
#registry = new ExtensionRegistry();
|
||||
#events = new EventBus();
|
||||
#setupContext: ExtensionSetupContext;
|
||||
#runtimeContext: ExtensionRuntimeContext;
|
||||
#extensionConfigs: Record<string, ExtensionConfig>;
|
||||
#state = "created";
|
||||
|
||||
constructor() {
|
||||
constructor(options: KernelOptions = {}) {
|
||||
const registry = this.#registry;
|
||||
const events = this.#events;
|
||||
this.#extensionConfigs = options.extensionConfigs ?? {};
|
||||
|
||||
this.#setupContext = {
|
||||
add(name, value) {
|
||||
@ -49,23 +62,27 @@ export class Kernel {
|
||||
}
|
||||
|
||||
get installedExtensionIds(): string[] {
|
||||
return this.#extensions.map((extension) => extension.id);
|
||||
return this.#extensions.map(({ id }) => id);
|
||||
}
|
||||
|
||||
use(...extensions: Extension[]): this {
|
||||
use(...factories: ExtensionFactory[]): this {
|
||||
if (this.#state !== "created") {
|
||||
throw new Error(`Cannot install extensions while kernel is ${this.#state}.`);
|
||||
}
|
||||
|
||||
const ids = new Set(this.#extensions.map((extension) => extension.id));
|
||||
const ids = new Set(this.#extensions.map(({ id }) => id));
|
||||
|
||||
for (const extension of extensions) {
|
||||
if (ids.has(extension.id)) {
|
||||
throw new Error(`Extension "${extension.id}" is already installed.`);
|
||||
for (const factory of factories) {
|
||||
if (ids.has(factory.id)) {
|
||||
throw new Error(`Extension "${factory.id}" is already installed.`);
|
||||
}
|
||||
ids.add(extension.id);
|
||||
ids.add(factory.id);
|
||||
}
|
||||
|
||||
const extensions = factories.map((factory) => ({
|
||||
id: factory.id,
|
||||
extension: factory({ ...this.#extensionConfigs[factory.id] }),
|
||||
}));
|
||||
this.#extensions.push(...extensions);
|
||||
return this;
|
||||
}
|
||||
@ -94,11 +111,11 @@ export class Kernel {
|
||||
this.#state = "starting";
|
||||
|
||||
try {
|
||||
for (const extension of this.#extensions) {
|
||||
for (const { extension } of this.#extensions) {
|
||||
await extension.setup(this.#setupContext);
|
||||
}
|
||||
|
||||
for (const extension of this.#extensions) {
|
||||
for (const { extension } of this.#extensions) {
|
||||
this.#activeExtensions.push(extension);
|
||||
await extension.start?.(this.#runtimeContext);
|
||||
}
|
||||
|
||||
14
src/main.ts
14
src/main.ts
@ -1,7 +1,8 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { loadEnvFile } from "node:process";
|
||||
|
||||
import { Event } from "./extensions/catalog";
|
||||
import { loadRuntimeConfig } from "./config";
|
||||
import { Event, ExtensionId } from "./extensions/catalog";
|
||||
import { Kernel } from "./kernel";
|
||||
import { getProduct, sharedExtensions } from "./products";
|
||||
|
||||
@ -9,13 +10,22 @@ if (existsSync(".env")) {
|
||||
loadEnvFile();
|
||||
}
|
||||
|
||||
const runtimeConfig = await loadRuntimeConfig();
|
||||
const productIds = process.argv.slice(2);
|
||||
const products = (productIds.length > 0 ? productIds : ["cli"]).map(getProduct);
|
||||
const factories = [
|
||||
...sharedExtensions,
|
||||
...products.flatMap((product) => product.extensions),
|
||||
];
|
||||
const kernel = new Kernel().use(...factories.map((factory) => factory()));
|
||||
const extensionConfigs = {
|
||||
...runtimeConfig.extensions,
|
||||
[ExtensionId.Workspace]: {
|
||||
...runtimeConfig.extensions[ExtensionId.Workspace],
|
||||
home: runtimeConfig.home,
|
||||
projectPath: runtimeConfig.projectPath,
|
||||
},
|
||||
};
|
||||
const kernel = new Kernel({ extensionConfigs }).use(...factories);
|
||||
|
||||
const stopped = new Promise<void>((resolve) => {
|
||||
kernel.on(Event.RuntimeStopRequested, resolve);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user