Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: Tests

on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
workflow_call:

jobs:
# 跨平台测试矩阵:脚本 sidecar 体系(fake sidecar + 真解释器 E2E)必须三平台都绿,
# 才能兑现"平台和 .NET 一致"的承诺。解释器经 setup 装上,needs-interpreter 用例才真跑
# (否则那条 vacuous pass,见 AGENTS.md 脚本节)。
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: "windows-latest"
- os: "ubuntu-latest"
- os: "macos-latest"

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x

- name: Install Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install Node 20
uses: actions/setup-node@v4
with:
node-version: "20"

# Lua 靶需要解释器:ubuntu/macos 装官方 5.4;windows 暂无打包(façade 逻辑单测在 posix 上跑,
# E2E 在 win 上 vacuous——见 ScriptedLuaE2ETests)。
- name: Install Lua (posix)
if: runner.os != 'Windows'
run: |
if [ "$RUNNER_OS" = "Linux" ]; then sudo apt-get update && sudo apt-get install -y lua5.4 && sudo ln -sf $(which lua5.4) /usr/local/bin/lua; fi
if [ "$RUNNER_OS" = "macOS" ]; then brew install lua; fi
shell: bash

- name: Restore and build
run: dotnet build TuneLab.sln -c Debug

# SetupCliOptions/SetupInstallManifest/SetupUninstall 三类走 TuneLab.Setup 程序集,
# 该工程带 RID 输出、测试二进制未必拷得进(本机三平台一致的既有环境问题)——先排除,
# 与脚本插件无关;单列跟进项修复拷盘后再纳入。
- name: Unit + scripted tests
run: >
dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj -c Debug --no-build
--filter "FullyQualifiedName!~SetupCliOptionsTests&FullyQualifiedName!~SetupInstallManifestTests&FullyQualifiedName!~SetupUninstallTests"

# 真解释器 scripted E2E 强制断言(缺解释器即红,而不是 vacuous pass)——CI 保证了 setup 装了。
# node/lua 经环境变量指到 setup/系统安装位(RuntimeLocator ② 级)。
- name: Real-interpreter scripted E2E
env:
TUNELAB_REQUIRE_PYTHON: "1"
TUNELAB_REQUIRE_NODE: "1"
TUNELAB_REQUIRE_LUA: "${{ runner.os != 'Windows' && '1' || '' }}"
run: >
dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj -c Debug --no-build
--filter "runtime=needs-interpreter"

- name: Python facade unit tests
run: python -m unittest discover -s scripting/python/tests

- name: Node facade unit tests
run: node --test scripting/node/test

- name: Lua facade unit tests (posix)
if: runner.os != 'Windows'
working-directory: scripting/lua
run: lua tests/test_codec.lua


- name: Legacy-compat tests
run: dotnet test legacy/compat/TuneLab.Hosting.Compat.Legacy.Tests/TuneLab.Hosting.Compat.Legacy.Tests.csproj -c Debug
31 changes: 30 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,36 @@
2. **Pack**: `pwsh tests/pack-tlx.ps1` → bundles `tests/packages/*` into `tests/tlx/*.tlx`.
3. **Install**: `pwsh tests/install-tlx.ps1 [names...]` → extracts each `.tlx` into
`%APPDATA%\TuneLab\Extensions\<manifest-name>\`, which is where the app actually loads from.
**TuneLab must be closed** first — a running instance locks the extension dlls.
**TuneLab must be closed** first — a running instance locks the extension dlls.

## Scripted plugins (sidecar) — see docs/scripted-plugin-sidecar-design.md

- Python/Node/Lua plugins run **out of process** (JSON-RPC over stdio); M0–M4b landed
(format/effect/voice, all three languages; shm audio plane for Python).
Façades live under `scripting/` (not in the .sln); wire spec: `scripting/wire/v1.json`.
The shm audio plane (M4b) is opt-in per façade: a façade declares `capabilities.audio_plane`
at handshake; only `shm` makes the host return a `region{path,data_offset,…}` from
`host.segment_create` (file-backed mapping under `TUNELAB_SHM_DIR`, body = interleaved f32-LE,
header 24B). On that plane the data body never crosses JSON — `commit` is the barrier.
Node/Lua stay `inline` (no stdlib mmap); Python maps via `mmap`/`memoryview` (numpy optional).
- Build & test (parallel to the dotnet pipeline):
- fake sidecar (protocol seal, no interpreter needed): built with the solution; the .NET test
project launches it via `dotnet exec`. Manual: `dotnet build tests/ScriptedFakeSidecar -c Debug`.
- Python façade: `python -m unittest discover -s scripting/python/tests` (pure stdlib).
- Node façade: `node --test scripting/node/test` (needs node>=18; zero-dependency package at
`scripting/node/tunelab/` — samples resolve it via `NODE_PATH=<repo>/scripting/node`).
- Lua façade: `lua tests/test_codec.lua` in `scripting/lua/` (pure Lua 5.1–5.4; no packaged
lua on windows — CI posix runs it; samples resolve via `LUA_PATH=<repo>/scripting/lua/?.lua;;`).
- Real-interpreter E2E: `dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj --filter "runtime=needs-interpreter"`
(set `TUNELAB_REQUIRE_PYTHON=1` / `TUNELAB_REQUIRE_NODE=1` / `TUNELAB_REQUIRE_LUA=1` to
fail-fast when the interpreter is not discoverable, else it passes vacuously;
`TUNELAB_PYTHON` / `TUNELAB_NODE` / `TUNELAB_LUA` pin the path — probing order: settings → env →
runtime packages → PATH).
- Scripted test plugins (e.g. `tests/plugins/PyFormat/`) are manifest + sources only — **no csproj,
no build step**; `pack-tlx.ps1` picks csproj-less folders under `tests/plugins/` up directly.
- `ProtocolVersion` (wire protocol, `Scripted/WireProtocol.cs`) is a third, independent version axis —
do not align it with `SdkVersion` or `AssemblyVersion`. Bump it together with a new
`scripting/wire/v<N>.json` only on incompatible frame/method changes (design doc §1.2).

## ⚠️ Frozen public ABI: TuneLab.SDK & TuneLab.Foundation

Expand Down
15 changes: 15 additions & 0 deletions TuneLab.sln
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TuneLab.Setup.Stub", "TuneL
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TuneLab.Cli", "TuneLab.Cli\TuneLab.Cli.csproj", "{4E729D7F-E690-4E80-9D67-9B4C5D75C962}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TuneLab.ScriptedFakeSidecar", "tests\ScriptedFakeSidecar\TuneLab.ScriptedFakeSidecar.csproj", "{5A860E05-D48C-4A56-8C52-CBECECBC040C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -169,12 +171,25 @@ Global
{4E729D7F-E690-4E80-9D67-9B4C5D75C962}.Release|x64.Build.0 = Release|Any CPU
{4E729D7F-E690-4E80-9D67-9B4C5D75C962}.Release|x86.ActiveCfg = Release|Any CPU
{4E729D7F-E690-4E80-9D67-9B4C5D75C962}.Release|x86.Build.0 = Release|Any CPU
{5A860E05-D48C-4A56-8C52-CBECECBC040C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5A860E05-D48C-4A56-8C52-CBECECBC040C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5A860E05-D48C-4A56-8C52-CBECECBC040C}.Debug|x64.ActiveCfg = Debug|Any CPU
{5A860E05-D48C-4A56-8C52-CBECECBC040C}.Debug|x64.Build.0 = Debug|Any CPU
{5A860E05-D48C-4A56-8C52-CBECECBC040C}.Debug|x86.ActiveCfg = Debug|Any CPU
{5A860E05-D48C-4A56-8C52-CBECECBC040C}.Debug|x86.Build.0 = Debug|Any CPU
{5A860E05-D48C-4A56-8C52-CBECECBC040C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5A860E05-D48C-4A56-8C52-CBECECBC040C}.Release|Any CPU.Build.0 = Release|Any CPU
{5A860E05-D48C-4A56-8C52-CBECECBC040C}.Release|x64.ActiveCfg = Release|Any CPU
{5A860E05-D48C-4A56-8C52-CBECECBC040C}.Release|x64.Build.0 = Release|Any CPU
{5A860E05-D48C-4A56-8C52-CBECECBC040C}.Release|x86.ActiveCfg = Release|Any CPU
{5A860E05-D48C-4A56-8C52-CBECECBC040C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{4F18DBBE-C75D-4CF2-BD32-254D31F4FD4E} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{5A860E05-D48C-4A56-8C52-CBECECBC040C} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {96245EFE-B915-471B-AC1A-8290E843A5BE}
Expand Down
1 change: 1 addition & 0 deletions TuneLab/Commands/CommandRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ internal static class CommandRegistry
new ExtensionSetSettingCommand(),
new ExtensionEnableCommand(),
new ExtensionInstallCommand(),
new ExtensionFetchRuntimeCommand(),
new ExtensionUninstallCommand(),
new ExtensionCancelUninstallCommand(),
};
Expand Down
97 changes: 97 additions & 0 deletions TuneLab/Commands/Handlers/ExtensionFetchRuntimeCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System;
using System.Globalization;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using TuneLab.Extensions;
using TuneLab.Extensions.Scripted;
using TuneLab.Utils;

namespace TuneLab.Commands.Handlers;

// `extension fetch-runtime` —— 脚本运行时的自动部署(设计文档 §8.6)。装一个解释器发行(python/node)
// 到扩展目录,成为一个 type:"runtime" 资源包;之后声明它的脚本插件(manifest `runtime`)在 ③ 级探测命中、
// 懒起。这是「用户已经下载了 .tlx 里的脚本插件、但机器上没有解释器」那条路的自动化替代。
//
// 与 `extension install` 同一授权档(ExtensionInstall):都往用户机器上放并在之后运行第三方代码。
// 差别只在来源——install 吃本地 .tlx,fetch-runtime 从镜像表下载(国内优先、竞速择快、可选 sha256 钉)。
internal sealed class ExtensionFetchRuntimeCommand : ICommand
{
public string Path => "extension fetch-runtime";
public CommandKind Kind => CommandKind.Edit;
public string AgentToolName => "fetch_scripted_runtime";

public string Brief => "Download and install a scripting runtime (Python / Node.js)";

public string Documentation =>
"Fetch an interpreter distribution (a 'runtime' resource package) so scripted extensions (manifest entries with a `runtime` field) can load. "
+ "Give it a runtime spec exactly like the manifest field: \"python>=3.10\" or \"node>=18\". It picks the fastest reachable mirror (domestic mirrors are tried before the official one), "
+ "verifies the download when the built-in feed pins a checksum, then installs it into the user's extensions folder and registers it \u2014 after which list_extensions / the failing scripted plugin will resolve. "
+ "\nIt downloads from the network and needs the user's authorization (same as installing a plugin). If nothing in the feed matches the platform, it refuses and the user must supply an interpreter themselves (set the path in settings, or install a 'runtime' .tlx). "
+ "\nAlready-satisfied interpreters are left alone: check the plugin's status first (list_extensions shows 'no matching runtime' only when one is genuinely missing).";

public string ParametersJsonSchema => """
{
"type": "object",
"properties": {
"runtime": { "type": "string", "description": "Runtime spec, same grammar as the manifest field, e.g. \"python>=3.10\" or \"node>=18\"." }
},
"required": ["runtime"],
"additionalProperties": false
}
""";

// 测试注入点:非空则用它替代真实下载(喂预制归档走完整落盘/登记链)。生产恒 null。
public ScriptedRuntimeFetcher.DownloadAsyncDelegate? DownloaderOverride;

public async Task<CommandResult> ExecuteAsync(CommandArgs args, CommandContext ctx, CancellationToken cancellationToken)
{
var specText = (args.Json.GetString("runtime") ?? "").Trim().Trim('"');
if (specText.Length == 0)
return CommandResult.Fail("empty_runtime", "\"runtime\" is required (e.g. \"python>=3.10\").");
if (!RuntimeSpec.TryParse(specText, out var spec, out var parseError))
return CommandResult.Fail("bad_runtime", parseError!);
var cell = RuntimeFeed.Match(spec);
if (cell == null)
return CommandResult.Fail("no_feed_cell", string.Format(CultureInfo.InvariantCulture,
"the built-in feed has no auto-installable runtime matching \"{0}\" for this platform ({1}); provide an interpreter yourself.",
spec.Describe(), PlatformHelper.GetPlatform()));

var label = string.Format(CultureInfo.InvariantCulture, "{0} {1}", cell.Value.Flavor, cell.Value.Version.ToString(3));
var (proceed, message) = await ctx.Authorize(new AuthorizationRequest(WriteKind.ExtensionInstall, 0, label, "runtime"), cancellationToken);
if (!proceed)
return CommandResult.Ok(new JsonObject { ["runtime"] = spec.Describe(), ["outcome"] = "refused", ["note"] = message });

var result = await ScriptedRuntimeFetcher.FetchInstallAsync(spec, null, cancellationToken, DownloaderOverride);
var node = new JsonObject
{
["runtime"] = spec.Describe(),
["resolved"] = label,
["outcome"] = result.Ok ? "applied" : "failed",
["note"] = result.Ok ? (string.IsNullOrEmpty(message) ? null : message) : result.Message,
};
if (result.PackageDir != null)
node["path"] = result.PackageDir;
return result.Ok ? CommandResult.Ok(node) : CommandResult.Fail("fetch_failed", result.Message, node);
}

public string Render(JsonNode? data, CommandArgs args)
{
if (data is not JsonObject obj)
return string.Empty;
var runtime = obj["runtime"]?.GetValue<string>() ?? "?";
var label = obj["resolved"]?.GetValue<string>() ?? runtime;
switch (obj["outcome"]?.GetValue<string>())
{
case "refused":
return obj["note"]?.GetValue<string>() ?? string.Empty;
case "failed":
return string.Format("Could not install the {0} runtime: {1}.", label, obj["note"]?.GetValue<string>() ?? "unknown error");
default:
return string.Format("Installed the {0} runtime into the extensions folder. Scripted plugins that asked for \"{1}\" "
+ "will pick it up on their next (re)load — from the Extensions sidebar use Reload, or restart TuneLab.",
label, runtime);
}
}
}
24 changes: 24 additions & 0 deletions TuneLab/Configs/SettingsRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,29 @@ internal static class SettingsRegistry
// 而不是让人去反复开关命令桥(那个开关只管连不连得上)。
+ "It is also the ceiling for the command bridge: an external tool (the tunelab CLI, an MCP server) never gets more than this, whatever it asks for.");

// ── 脚本运行时(sidecar;docs/scripted-plugin-sidecar-design.md §8)——M0 起仅存储,
// "设置 → 扩展 → 运行时"页与下载按钮随 M1 挂 UI;现在可经 agent set_setting / 手改 settings.json /
// 环境变量(TUNELAB_PYTHON 等)设定。值是解释器可执行文件路径,分号分隔多候选(按序核验,首个真跑通过的赢)。
public static readonly SettingItem<string> ScriptedRuntimePython = Str("ScriptedRuntimePython", null,
"Scripted Runtime: Python", TextBoxConfig.Create(), string.Empty,
description: "Absolute path(s) of the Python interpreter for scripted plugins (semicolon-separated candidates). " +
"Probing order: this setting, then TUNELAB_PYTHON, then installed runtime packages, then PATH.");
public static readonly SettingItem<string> ScriptedRuntimeNode = Str("ScriptedRuntimeNode", null,
"Scripted Runtime: Node.js", TextBoxConfig.Create(), string.Empty,
description: "Absolute path(s) of the Node.js executable for scripted plugins (semicolon-separated candidates). " +
"Probing order: this setting, then TUNELAB_NODE, then installed runtime packages, then PATH.");
public static readonly SettingItem<string> ScriptedRuntimeLua = Str("ScriptedRuntimeLua", null,
"Scripted Runtime: Lua", TextBoxConfig.Create(), string.Empty,
description: "Absolute path(s) of the Lua interpreter for scripted plugins (semicolon-separated candidates; use TUNELAB_LUAJIT for the luajit flavor). " +
"Probing order: this setting, then TUNELAB_LUA / TUNELAB_LUAJIT, then installed runtime packages, then PATH.");
public static readonly SettingItem<bool> ScriptedAutoFetchRuntime = Bool("ScriptedAutoFetchRuntime", null,
"Auto Fetch Scripted Runtimes", CheckBoxConfig.Create(false), false,
description: "When on, a missing interpreter for a scripted plugin is downloaded automatically (checksum-pinned runtime package); " +
"when off, the sidebar offers a one-click download that asks first. Offline / air-gapped: keep it off and provide the interpreter yourself.");
public static readonly SettingItem<string> ScriptedRuntimeSource = Str("ScriptedRuntimeSource", null,
"Scripted Runtime Download Source", TextBoxConfig.Create(), string.Empty,
description: "Override base URL for runtime package downloads (mirrors / intranet). Checksum verification still applies; empty means the official release feed.");

// 全部条目——顺序 = 【设置窗行序】(tab 分组、组内重要项在前),是单一受控顺序源:
// 设置窗 All.Where(Tab==tab) 渲染、agent list_settings 同序。末尾是仅存储的孤儿设置(无 tab、不渲染)。
// 磁盘 JSON 键随此顺序写出(分组排列)——键顺序无语义、按键名加载,不影响兼容。
Expand All @@ -190,6 +213,7 @@ internal static class SettingsRegistry
ParameterBoundaryExtension, ParameterSyncMode, AutoGeneratePronunciation,
// 仅存储(无设置窗行)
AutoScrollTarget, AgentModelProvider, AgentAuthorization,
ScriptedRuntimePython, ScriptedRuntimeNode, ScriptedRuntimeLua, ScriptedAutoFetchRuntime, ScriptedRuntimeSource,
];

// ── 工厂 + 转换器 ──
Expand Down
Loading
Loading