| author | |
| committer | |
| log | 953edeec9dcddd7afc582926eb1b65cfd82dc784 |
| tree | 742ae43c13f40cb5f81402b2ab55eb87401937d4 |
| parent | 74dfbb697235b0db87355d3f8575dc2ba45dddf7 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Theo Browne <me@t3.gg>8 files changed, 377 insertions(+), 20 deletions(-)
apps/server/scripts/record-muse-msp-replay-fixture.ts+19-5| ... | @@ -27,6 +27,7 @@ import { TestClock } from "effect/testing"; | ... | @@ -27,6 +27,7 @@ import { TestClock } from "effect/testing"; |
| 27 | import { | 27 | import { |
| 28 | connectMuseTransport, | 28 | connectMuseTransport, |
| 29 | layer as museReplayLayer, | 29 | layer as museReplayLayer, |
| 30 | materializeMuseReplayWorkspace, | ||
| 30 | MUSE_MSP_REPLAY_PROTOCOL, | 31 | MUSE_MSP_REPLAY_PROTOCOL, |
| 31 | MUSE_PROVIDER_KIND, | 32 | MUSE_PROVIDER_KIND, |
| 32 | museRecordLabel, | 33 | museRecordLabel, |
| ... | @@ -35,14 +36,15 @@ import { | ... | @@ -35,14 +36,15 @@ import { |
| 35 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 36 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 36 | import { | 37 | import { |
| 37 | makeMuseEnvironment, | 38 | makeMuseEnvironment, |
| 39 | museLaunch, | ||
| 38 | museServeArgs, | 40 | museServeArgs, |
| 41 | museWorkspaceRoot, | ||
| 39 | parseMuseVersion, | 42 | parseMuseVersion, |
| 40 | } from "@t3tools/provider-muse/testing"; | 43 | } from "@t3tools/provider-muse/testing"; |
| 41 | import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; | 44 | import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; |
| 42 | import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; | 45 | import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; |
| 43 | import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; | 46 | import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; |
| 44 | import { runOrchestratorV2ProviderReplayScenario } from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; | 47 | import { runOrchestratorV2ProviderReplayScenario } from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; |
| 45 | import { materializeReplayTranscriptWorkspace } from "@t3tools/provider-testing/replayTranscript"; | ||
| 46 | import { | 48 | import { |
| 47 | checkpointWorkspace, | 49 | checkpointWorkspace, |
| 48 | makeCheckpointWorkspace, | 50 | makeCheckpointWorkspace, |
| ... | @@ -66,6 +68,8 @@ if (fixture === undefined || variant === undefined) { | ... | @@ -66,6 +68,8 @@ if (fixture === undefined || variant === undefined) { |
| 66 | } | 68 | } |
| 67 | 69 | ||
| 68 | const museBinary = process.env.T3_MUSE_BIN ?? "muse"; | 70 | const museBinary = process.env.T3_MUSE_BIN ?? "muse"; |
| 71 | // Started as T3 starts it, so Windows needs no T3_MUSE_BIN for Muse's `muse.cmd` launcher. | ||
| 72 | const museStart = Effect.runSync(museLaunch(museBinary)); | ||
| 69 | const home = process.env.HOME ?? ""; | 73 | const home = process.env.HOME ?? ""; |
| 70 | 74 | ||
| 71 | function isRecord(value: unknown): value is Record<string, unknown> { | 75 | function isRecord(value: unknown): value is Record<string, unknown> { |
| ... | @@ -147,7 +151,7 @@ function makeRecordingCreateHost( | ... | @@ -147,7 +151,7 @@ function makeRecordingCreateHost( |
| 147 | }; | 151 | }; |
| 148 | record("expect_outbound", { type: "host_start", args }); | 152 | record("expect_outbound", { type: "host_start", args }); |
| 149 | // The binary named by T3_MUSE_BIN, so the recorded version matches what ran. | 153 | // The binary named by T3_MUSE_BIN, so the recorded version matches what ran. |
| 150 | const child = NodeChildProcess.spawn(museBinary, args, { | 154 | const child = NodeChildProcess.spawn(museStart.command, [...museStart.args, ...args], { |
| 151 | cwd: options.cwd, | 155 | cwd: options.cwd, |
| 152 | env: options.environment ?? makeMuseEnvironment(), | 156 | env: options.environment ?? makeMuseEnvironment(), |
| 153 | stdio: ["pipe", "pipe", "inherit"], | 157 | stdio: ["pipe", "pipe", "inherit"], |
| ... | @@ -208,7 +212,11 @@ function makeRecordingCreateHost( | ... | @@ -208,7 +212,11 @@ function makeRecordingCreateHost( |
| 208 | } | 212 | } |
| 209 | 213 | ||
| 210 | const readMuseVersion = Effect.sync(() => { | 214 | const readMuseVersion = Effect.sync(() => { |
| 211 | const output = NodeChildProcess.execFileSync(museBinary, ["--version"], { encoding: "utf8" }); | 215 | const output = NodeChildProcess.execFileSync( |
| 216 | museStart.command, | ||
| 217 | [...museStart.args, "--version"], | ||
| 218 | { encoding: "utf8" }, | ||
| 219 | ); | ||
| 212 | return parseMuseVersion(output) ?? output.trim(); | 220 | return parseMuseVersion(output) ?? output.trim(); |
| 213 | }); | 221 | }); |
| 214 | 222 | ||
| ... | @@ -224,6 +232,7 @@ const record = Effect.gen(function* () { | ... | @@ -224,6 +232,7 @@ const record = Effect.gen(function* () { |
| 224 | yield* Effect.addFinalizer(() => | 232 | yield* Effect.addFinalizer(() => |
| 225 | fs.remove(workspace, { recursive: true, force: true }).pipe(Effect.ignore), | 233 | fs.remove(workspace, { recursive: true, force: true }).pipe(Effect.ignore), |
| 226 | ); | 234 | ); |
| 235 | const canonicalWorkspace = yield* fs.realPath(workspace); | ||
| 227 | 236 | ||
| 228 | const entries: Array<ProviderReplayEntry> = []; | 237 | const entries: Array<ProviderReplayEntry> = []; |
| 229 | const commandIds: Array<Array<string>> = []; | 238 | const commandIds: Array<Array<string>> = []; |
| ... | @@ -276,7 +285,12 @@ const record = Effect.gen(function* () { | ... | @@ -276,7 +285,12 @@ const record = Effect.gen(function* () { |
| 276 | model: variant.modelSelection.model, | 285 | model: variant.modelSelection.model, |
| 277 | commandIds, | 286 | commandIds, |
| 278 | }, | 287 | }, |
| 279 | entries: normalizeEntries(entries, [yield* fs.realPath(workspace), workspace]), | 288 | // The verbatim root first: it contains the canonical path. |
| 289 | entries: normalizeEntries(entries, [ | ||
| 290 | yield* museWorkspaceRoot(canonicalWorkspace), | ||
| 291 | canonicalWorkspace, | ||
| 292 | workspace, | ||
| 293 | ]), | ||
| 280 | } satisfies ProviderReplayTranscript; | 294 | } satisfies ProviderReplayTranscript; |
| 281 | 295 | ||
| 282 | yield* Effect.gen(function* () { | 296 | yield* Effect.gen(function* () { |
| ... | @@ -286,7 +300,7 @@ const record = Effect.gen(function* () { | ... | @@ -286,7 +300,7 @@ const record = Effect.gen(function* () { |
| 286 | { | 300 | { |
| 287 | name: `${fixture.name}/muse:verify`, | 301 | name: `${fixture.name}/muse:verify`, |
| 288 | transcript: yield* MuseOrchestratorReplayHarness.decodeTranscript( | 302 | transcript: yield* MuseOrchestratorReplayHarness.decodeTranscript( |
| 289 | materializeReplayTranscriptWorkspace(transcript, yield* fs.realPath(replayWorkspace)), | 303 | yield* materializeMuseReplayWorkspace(transcript, replayWorkspace), |
| 290 | ), | 304 | ), |
| 291 | commands: materialized.commands, | 305 | commands: materialized.commands, |
| 292 | steps: materialized.steps, | 306 | steps: materialized.steps, |
apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts+32| ... | @@ -24,6 +24,7 @@ import { | ... | @@ -24,6 +24,7 @@ import { |
| 24 | } from "@t3tools/contracts"; | 24 | } from "@t3tools/contracts"; |
| 25 | import { MuseSettings } from "@t3tools/provider-muse/settings"; | 25 | import { MuseSettings } from "@t3tools/provider-muse/settings"; |
| 26 | import * as Effect from "effect/Effect"; | 26 | import * as Effect from "effect/Effect"; |
| 27 | import * as FileSystem from "effect/FileSystem"; | ||
| 27 | import * as Layer from "effect/Layer"; | 28 | import * as Layer from "effect/Layer"; |
| 28 | import * as Schema from "effect/Schema"; | 29 | import * as Schema from "effect/Schema"; |
| 29 | 30 | ||
| ... | @@ -31,6 +32,7 @@ import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; | ... | @@ -31,6 +32,7 @@ import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; |
| 31 | import { | 32 | import { |
| 32 | museInitializeParams, | 33 | museInitializeParams, |
| 33 | museServeArgs, | 34 | museServeArgs, |
| 35 | museWorkspaceRoot, | ||
| 34 | type MuseSdkHost, | 36 | type MuseSdkHost, |
| 35 | type MuseSdkHostOptions, | 37 | type MuseSdkHostOptions, |
| 36 | } from "@t3tools/provider-muse/testing"; | 38 | } from "@t3tools/provider-muse/testing"; |
| ... | @@ -38,6 +40,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | ... | @@ -38,6 +40,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 38 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; | 40 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; |
| 39 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; | 41 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; |
| 40 | import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; | 42 | import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; |
| 43 | import { materializeReplayTranscriptWorkspace } from "@t3tools/provider-testing/replayTranscript"; | ||
| 41 | import { makeMuseAdapterV2 } from "@t3tools/provider-muse/server"; | 44 | import { makeMuseAdapterV2 } from "@t3tools/provider-muse/server"; |
| 42 | 45 | ||
| 43 | export const MUSE_PROVIDER_KIND = "muse"; | 46 | export const MUSE_PROVIDER_KIND = "muse"; |
| ... | @@ -468,6 +471,35 @@ export function layer(input: { | ... | @@ -468,6 +471,35 @@ export function layer(input: { |
| 468 | ); | 471 | ); |
| 469 | } | 472 | } |
| 470 | 473 | ||
| 474 | /** | ||
| 475 | * Fills a recording's `<workspace>` with what the adapter sends on this host: | ||
| 476 | * the canonical path (macOS /var -> /private/var), which `turn/start` names in | ||
| 477 | * museWorkspaceRoot's form (verbatim on Windows). | ||
| 478 | */ | ||
| 479 | export const materializeMuseReplayWorkspace = Effect.fn("materializeMuseReplayWorkspace")( | ||
| 480 | function* (transcript: ProviderReplayTranscript, workspace: string) { | ||
| 481 | const canonical = yield* FileSystem.FileSystem.pipe( | ||
| 482 | Effect.flatMap((fs) => fs.realPath(workspace)), | ||
| 483 | Effect.provide(NodeServices.layer), | ||
| 484 | ); | ||
| 485 | const plain = materializeReplayTranscriptWorkspace(transcript, canonical); | ||
| 486 | const turnStarts = materializeReplayTranscriptWorkspace( | ||
| 487 | transcript, | ||
| 488 | yield* museWorkspaceRoot(canonical), | ||
| 489 | ); | ||
| 490 | return { | ||
| 491 | ...plain, | ||
| 492 | entries: plain.entries.map((entry, index) => | ||
| 493 | entry.type === "expect_outbound" && | ||
| 494 | isRecord(entry.frame) && | ||
| 495 | entry.frame.method === "turn/start" | ||
| 496 | ? turnStarts.entries[index]! | ||
| 497 | : entry, | ||
| 498 | ), | ||
| 499 | }; | ||
| 500 | }, | ||
| 501 | ); | ||
| 502 | |||
| 471 | export const MuseOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< | 503 | export const MuseOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< |
| 472 | MuseReplayTranscript, | 504 | MuseReplayTranscript, |
| 473 | MuseReplayTranscriptDecodeError | 505 | MuseReplayTranscriptDecodeError |
apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts+5-9| ... | @@ -19,7 +19,10 @@ import { | ... | @@ -19,7 +19,10 @@ import { |
| 19 | OPENCODE2_HTTP_PROTOCOL, | 19 | OPENCODE2_HTTP_PROTOCOL, |
| 20 | OpenCode2OrchestratorReplayHarness, | 20 | OpenCode2OrchestratorReplayHarness, |
| 21 | } from "../Adapters/OpenCode2AdapterV2.testkit.ts"; | 21 | } from "../Adapters/OpenCode2AdapterV2.testkit.ts"; |
| 22 | import { MuseOrchestratorReplayHarness } from "../Adapters/MuseAdapterV2.testkit.ts"; | 22 | import { |
| 23 | materializeMuseReplayWorkspace, | ||
| 24 | MuseOrchestratorReplayHarness, | ||
| 25 | } from "../Adapters/MuseAdapterV2.testkit.ts"; | ||
| 23 | import { PiOrchestratorReplayHarness } from "../Adapters/PiAdapterV2.testkit.ts"; | 26 | import { PiOrchestratorReplayHarness } from "../Adapters/PiAdapterV2.testkit.ts"; |
| 24 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 27 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 25 | import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; | 28 | import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; |
| ... | @@ -101,18 +104,11 @@ const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* < | ... | @@ -101,18 +104,11 @@ const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* < |
| 101 | ); | 104 | ); |
| 102 | const fixtureInput = input.buildInput(); | 105 | const fixtureInput = input.buildInput(); |
| 103 | const workspace = yield* checkpointWorkspace(input.fixtureName, fixtureInput.workspaceFiles); | 106 | const workspace = yield* checkpointWorkspace(input.fixtureName, fixtureInput.workspaceFiles); |
| 104 | // Muse canonicalizes its workspace path (macOS /var -> /private/var) before sending it. | ||
| 105 | const transcript = yield* input.harness.decodeTranscript( | 107 | const transcript = yield* input.harness.decodeTranscript( |
| 106 | input.driver.driver === "codex" | 108 | input.driver.driver === "codex" |
| 107 | ? materializeReplayTranscriptWorkspace(replayTranscript, workspace) | 109 | ? materializeReplayTranscriptWorkspace(replayTranscript, workspace) |
| 108 | : input.driver.driver === "muse" | 110 | : input.driver.driver === "muse" |
| 109 | ? materializeReplayTranscriptWorkspace( | 111 | ? yield* materializeMuseReplayWorkspace(replayTranscript, workspace) |
| 110 | replayTranscript, | ||
| 111 | yield* FileSystem.FileSystem.pipe( | ||
| 112 | Effect.flatMap((fs) => fs.realPath(workspace)), | ||
| 113 | Effect.provide(NodeServices.layer), | ||
| 114 | ), | ||
| 115 | ) | ||
| 116 | : replayTranscript, | 112 | : replayTranscript, |
| 117 | ); | 113 | ); |
| 118 | const materialized = yield* materializeFixtureInput({ | 114 | const materialized = yield* materializeFixtureInput({ |
packages/provider-muse/src/server/adapter.test.ts+36| ... | @@ -18,6 +18,7 @@ import { | ... | @@ -18,6 +18,7 @@ import { |
| 18 | type OrchestrationV2AppThread, | 18 | type OrchestrationV2AppThread, |
| 19 | type OrchestrationV2ProviderThread, | 19 | type OrchestrationV2ProviderThread, |
| 20 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| 21 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 21 | import { MuseSettings } from "../settings.ts"; | 22 | import { MuseSettings } from "../settings.ts"; |
| 22 | import * as DateTime from "effect/DateTime"; | 23 | import * as DateTime from "effect/DateTime"; |
| 23 | import * as Deferred from "effect/Deferred"; | 24 | import * as Deferred from "effect/Deferred"; |
| ... | @@ -499,6 +500,41 @@ describe("MuseAdapterV2", () => { | ... | @@ -499,6 +500,41 @@ describe("MuseAdapterV2", () => { |
| 499 | }).pipe(Effect.scoped, Effect.provide(testLayer)), | 500 | }).pipe(Effect.scoped, Effect.provide(testLayer)), |
| 500 | ); | 501 | ); |
| 501 | 502 | ||
| 503 | it.effect("pins Windows turns to Muse's verbatim root and starts Muse in the plain path", () => | ||
| 504 | Effect.gen(function* () { | ||
| 505 | const fake = yield* makeFakeMuse(); | ||
| 506 | let hostCwd: string | undefined; | ||
| 507 | const policy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ | ||
| 508 | ...runtimePolicy, | ||
| 509 | cwd: "C:\\work\\repo", | ||
| 510 | }); | ||
| 511 | const harness = yield* makeHarness( | ||
| 512 | fake, | ||
| 513 | INSTANCE_ID, | ||
| 514 | undefined, | ||
| 515 | undefined, | ||
| 516 | undefined, | ||
| 517 | policy, | ||
| 518 | { | ||
| 519 | createHost: async (options) => { | ||
| 520 | hostCwd = options.cwd; | ||
| 521 | return fake.host; | ||
| 522 | }, | ||
| 523 | }, | ||
| 524 | ); | ||
| 525 | yield* startConversation(harness, fake); | ||
| 526 | const turnStart = fake.calls.find((call) => call.method === "turn/start"); | ||
| 527 | // Muse 1.4.3 rejects any other form: "expected a canonical path (resolves to \\?\C:\…)". | ||
| 528 | assert.deepStrictEqual(turnStart?.params.workspaceRoots, ["\\\\?\\C:\\work\\repo"]); | ||
| 529 | // The muse.cmd launcher runs under cmd.exe, which cannot start in a verbatim directory. | ||
| 530 | assert.strictEqual(hostCwd, "C:\\work\\repo"); | ||
| 531 | }).pipe( | ||
| 532 | Effect.provideService(HostProcess.Platform, "win32"), | ||
| 533 | Effect.scoped, | ||
| 534 | Effect.provide(testLayer), | ||
| 535 | ), | ||
| 536 | ); | ||
| 537 | |||
| 502 | it.effect("skips an unreadable usage notification without ending the turn", () => | 538 | it.effect("skips an unreadable usage notification without ending the turn", () => |
| 503 | Effect.gen(function* () { | 539 | Effect.gen(function* () { |
| 504 | const fake = yield* makeFakeMuse(); | 540 | const fake = yield* makeFakeMuse(); |
packages/provider-muse/src/server/adapter.ts+3-1| ... | @@ -62,6 +62,7 @@ import { | ... | @@ -62,6 +62,7 @@ import { |
| 62 | import { | 62 | import { |
| 63 | createMuseSdkHostEffect, | 63 | createMuseSdkHostEffect, |
| 64 | museApprovalMode, | 64 | museApprovalMode, |
| 65 | museWorkspaceRoot, | ||
| 65 | type createMuseSdkHost, | 66 | type createMuseSdkHost, |
| 66 | type MuseSdkHost, | 67 | type MuseSdkHost, |
| 67 | } from "./sdk.ts"; | 68 | } from "./sdk.ts"; |
| ... | @@ -277,6 +278,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( | ... | @@ -277,6 +278,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( |
| 277 | const cwd = yield* fileSystem | 278 | const cwd = yield* fileSystem |
| 278 | .realPath(requestedCwd) | 279 | .realPath(requestedCwd) |
| 279 | .pipe(Effect.orElseSucceed(() => requestedCwd)); | 280 | .pipe(Effect.orElseSucceed(() => requestedCwd)); |
| 281 | const workspaceRoot = yield* museWorkspaceRoot(cwd); | ||
| 280 | const now = yield* DateTime.now; | 282 | const now = yield* DateTime.now; |
| 281 | let session: OrchestrationV2ProviderSession = { | 283 | let session: OrchestrationV2ProviderSession = { |
| 282 | id: input.providerSessionId, | 284 | id: input.providerSessionId, |
| ... | @@ -1751,7 +1753,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( | ... | @@ -1751,7 +1753,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( |
| 1751 | displayText: turnInput.message.text || "Image attachment", | 1753 | displayText: turnInput.message.text || "Image attachment", |
| 1752 | // A resumed session keeps the root it was created with; pin it to | 1754 | // A resumed session keeps the root it was created with; pin it to |
| 1753 | // this thread's current checkout so edits land where T3 tracks them. | 1755 | // this thread's current checkout so edits land where T3 tracks them. |
| 1754 | workspaceRoots: [cwd], | 1756 | workspaceRoots: [workspaceRoot], |
| 1755 | ifBusy: "queue", | 1757 | ifBusy: "queue", |
| 1756 | ...(effort ? { reasoningEffort: effort } : {}), | 1758 | ...(effort ? { reasoningEffort: effort } : {}), |
| 1757 | }, | 1759 | }, |
packages/provider-muse/src/server/sdk.test.ts+194| ... | @@ -1,13 +1,23 @@ | ... | @@ -1,13 +1,23 @@ |
| 1 | // @effect-diagnostics-next-line nodeBuiltinImport:off -- The Muse SDK spawns with Node's child_process, so the launch test quotes arguments the same way. | ||
| 2 | import * as NodeChildProcess from "node:child_process"; | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 1 | import { spawnMspConnection, type MspHandshake, type SpawnedMspConnection } from "@muse-code/sdk"; | 4 | import { spawnMspConnection, type MspHandshake, type SpawnedMspConnection } from "@muse-code/sdk"; |
| 2 | import { it } from "@effect/vitest"; | 5 | import { it } from "@effect/vitest"; |
| 6 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 7 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; | ||
| 3 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; | 9 | import * as Fiber from "effect/Fiber"; |
| 10 | import * as FileSystem from "effect/FileSystem"; | ||
| 11 | import * as Path from "effect/Path"; | ||
| 5 | import { afterEach, describe, expect, vi } from "vite-plus/test"; | 12 | import { afterEach, describe, expect, vi } from "vite-plus/test"; |
| 6 | import { | 13 | import { |
| 7 | createMuseSdkHost, | 14 | createMuseSdkHost, |
| 8 | createMuseSdkHostEffect, | 15 | createMuseSdkHostEffect, |
| 9 | makeMuseEnvironment, | 16 | makeMuseEnvironment, |
| 10 | museApprovalMode, | 17 | museApprovalMode, |
| 18 | museLaunch, | ||
| 19 | museVerbatimPath, | ||
| 20 | museWorkspaceRoot, | ||
| 11 | type MuseSdkHost, | 21 | type MuseSdkHost, |
| 12 | } from "./sdk.ts"; | 22 | } from "./sdk.ts"; |
| 13 | 23 | ||
| ... | @@ -264,3 +274,187 @@ describe("Muse SDK host", () => { | ... | @@ -264,3 +274,187 @@ describe("Muse SDK host", () => { |
| 264 | }), | 274 | }), |
| 265 | ); | 275 | ); |
| 266 | }); | 276 | }); |
| 277 | |||
| 278 | describe("Muse launch", () => { | ||
| 279 | const CMD = "C:\\Windows\\system32\\cmd.exe"; | ||
| 280 | const onWindows = (resolved: string | undefined, env: NodeJS.ProcessEnv = { ComSpec: CMD }) => | ||
| 281 | museLaunch("muse", env).pipe( | ||
| 282 | Effect.provideService(HostProcess.Platform, "win32"), | ||
| 283 | Effect.provideService(SpawnExecutableResolution, () => resolved), | ||
| 284 | ); | ||
| 285 | |||
| 286 | it.effect("starts the binary itself off Windows", () => | ||
| 287 | Effect.gen(function* () { | ||
| 288 | expect(yield* museLaunch("muse")).toEqual({ command: "muse", args: [] }); | ||
| 289 | }).pipe( | ||
| 290 | Effect.provideService(HostProcess.Platform, "linux"), | ||
| 291 | Effect.provideService(SpawnExecutableResolution, () => { | ||
| 292 | throw new Error("Nothing is resolved off Windows."); | ||
| 293 | }), | ||
| 294 | ), | ||
| 295 | ); | ||
| 296 | |||
| 297 | // Node spawns neither a bare `muse` nor a `.cmd` file without a shell on Windows. | ||
| 298 | it.effect("runs an exe directly and a launcher script under cmd.exe on Windows", () => | ||
| 299 | Effect.gen(function* () { | ||
| 300 | expect(yield* onWindows("C:\\Muse\\muse.exe")).toEqual({ | ||
| 301 | command: "C:\\Muse\\muse.exe", | ||
| 302 | args: [], | ||
| 303 | }); | ||
| 304 | // Node quotes a path with spaces, and the `@` before it keeps cmd from dropping the quotes. | ||
| 305 | const spaced = "C:\\Users\\Jane Doe\\AppData\\Local\\Programs\\muse\\muse.cmd"; | ||
| 306 | expect(yield* onWindows(spaced)).toEqual({ command: CMD, args: ["/d", "/c", "@", spaced] }); | ||
| 307 | const spacedAmpersand = "C:\\Users\\R&D Team\\AppData\\Local\\Programs\\muse\\muse.cmd"; | ||
| 308 | expect(yield* onWindows(spacedAmpersand)).toEqual({ | ||
| 309 | command: CMD, | ||
| 310 | args: ["/d", "/c", "@", spacedAmpersand], | ||
| 311 | }); | ||
| 312 | // Unquoted, cmd would split the command at `&`. | ||
| 313 | expect(yield* onWindows("C:\\Users\\R&D(1)\\muse.cmd")).toEqual({ | ||
| 314 | command: CMD, | ||
| 315 | args: ["/d", "/c", "C:\\Users\\R^&D^(1^)\\muse.cmd"], | ||
| 316 | }); | ||
| 317 | expect((yield* onWindows("C:\\Muse\\muse.bat", { SYSTEMROOT: "C:\\Windows" })).command).toBe( | ||
| 318 | "C:\\Windows\\System32\\cmd.exe", | ||
| 319 | ); | ||
| 320 | // A binary that cannot be found is spawned as given, so the failure reports it. | ||
| 321 | expect(yield* onWindows(undefined)).toEqual({ command: "muse", args: [] }); | ||
| 322 | }), | ||
| 323 | ); | ||
| 324 | |||
| 325 | // cmd's quote rules only show with a real cmd.exe, spawning as the SDK does. | ||
| 326 | it.effect("starts a real launcher from a folder with spaces and cmd metacharacters", () => | ||
| 327 | Effect.gen(function* () { | ||
| 328 | if ((yield* HostProcess.Platform) !== "win32") return; | ||
| 329 | const fs = yield* FileSystem.FileSystem; | ||
| 330 | const path = yield* Path.Path; | ||
| 331 | const folder = path.join( | ||
| 332 | yield* fs.makeTempDirectoryScoped({ prefix: "MuseLaunch-" }), | ||
| 333 | "R&D Team (x) @ ^y", | ||
| 334 | ); | ||
| 335 | yield* fs.makeDirectory(folder); | ||
| 336 | const launcher = path.join(folder, "muse.cmd"); | ||
| 337 | // Records its arguments next to itself; reaching the file at all proves cmd found it. | ||
| 338 | yield* fs.writeFileString(launcher, '@echo off\r\necho %*>"%~dp0args.txt"\r\n'); | ||
| 339 | const launch = yield* museLaunch(launcher); | ||
| 340 | const run = NodeChildProcess.spawnSync(launch.command, [...launch.args, "serve", "--x"], { | ||
| 341 | cwd: folder, | ||
| 342 | encoding: "utf8", | ||
| 343 | }); | ||
| 344 | expect(run.status, run.stderr).toBe(0); | ||
| 345 | expect((yield* fs.readFileString(path.join(folder, "args.txt"))).trim()).toBe("serve --x"); | ||
| 346 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 347 | ); | ||
| 348 | |||
| 349 | it.effect("starts Muse's Windows launcher through cmd.exe", () => | ||
| 350 | Effect.gen(function* () { | ||
| 351 | const fake = mockSpawn(); | ||
| 352 | const starting = yield* Effect.forkChild( | ||
| 353 | createMuseSdkHostEffect({ | ||
| 354 | binaryPath: "muse", | ||
| 355 | environment: { ComSpec: CMD }, | ||
| 356 | readOnly: true, | ||
| 357 | }), | ||
| 358 | ); | ||
| 359 | yield* Effect.promise(() => fake.initializing.promise); | ||
| 360 | const spawned = vi.mocked(spawnMspConnection).mock.lastCall?.[0]; | ||
| 361 | fake.startup.resolve(fake.ready); | ||
| 362 | const host = yield* Fiber.join(starting); | ||
| 363 | fake.shutdown.resolve({ code: 0, signal: null }); | ||
| 364 | yield* Effect.promise(() => host.close()); | ||
| 365 | expect(spawned).toMatchObject({ | ||
| 366 | command: CMD, | ||
| 367 | args: [ | ||
| 368 | "/d", | ||
| 369 | "/c", | ||
| 370 | "C:\\Muse\\muse.cmd", | ||
| 371 | "serve", | ||
| 372 | "--disable-shell", | ||
| 373 | "--disable-write", | ||
| 374 | "--no-session-log", | ||
| 375 | ], | ||
| 376 | }); | ||
| 377 | }).pipe( | ||
| 378 | Effect.provideService(HostProcess.Platform, "win32"), | ||
| 379 | Effect.provideService(SpawnExecutableResolution, () => "C:\\Muse\\muse.cmd"), | ||
| 380 | ), | ||
| 381 | ); | ||
| 382 | |||
| 383 | it.effect("passes launcher arguments to the binary that cmd.exe starts", () => | ||
| 384 | Effect.gen(function* () { | ||
| 385 | const fake = mockSpawn(); | ||
| 386 | const starting = yield* Effect.forkChild( | ||
| 387 | createMuseSdkHostEffect({ | ||
| 388 | binaryPath: "muse", | ||
| 389 | launchArgs: ["--launcher-arg"], | ||
| 390 | environment: { ComSpec: CMD }, | ||
| 391 | readOnly: true, | ||
| 392 | }), | ||
| 393 | ); | ||
| 394 | yield* Effect.promise(() => fake.initializing.promise); | ||
| 395 | const spawned = vi.mocked(spawnMspConnection).mock.lastCall?.[0]; | ||
| 396 | fake.startup.resolve(fake.ready); | ||
| 397 | const host = yield* Fiber.join(starting); | ||
| 398 | fake.shutdown.resolve({ code: 0, signal: null }); | ||
| 399 | yield* Effect.promise(() => host.close()); | ||
| 400 | expect(spawned).toMatchObject({ | ||
| 401 | command: CMD, | ||
| 402 | args: [ | ||
| 403 | "/d", | ||
| 404 | "/c", | ||
| 405 | "C:\\Muse\\muse.cmd", | ||
| 406 | "--launcher-arg", | ||
| 407 | "serve", | ||
| 408 | "--disable-shell", | ||
| 409 | "--disable-write", | ||
| 410 | "--no-session-log", | ||
| 411 | ], | ||
| 412 | }); | ||
| 413 | }).pipe( | ||
| 414 | Effect.provideService(HostProcess.Platform, "win32"), | ||
| 415 | Effect.provideService(SpawnExecutableResolution, () => "C:\\Muse\\muse.cmd"), | ||
| 416 | ), | ||
| 417 | ); | ||
| 418 | }); | ||
| 419 | |||
| 420 | describe("Muse workspace root", () => { | ||
| 421 | it("writes Windows paths in their verbatim form", () => { | ||
| 422 | expect(museVerbatimPath("C:\\Users\\Person\\repo")).toBe("\\\\?\\C:\\Users\\Person\\repo"); | ||
| 423 | expect(museVerbatimPath("\\\\server\\share\\repo")).toBe("\\\\?\\UNC\\server\\share\\repo"); | ||
| 424 | expect(museVerbatimPath("\\\\?\\C:\\repo")).toBe("\\\\?\\C:\\repo"); | ||
| 425 | }); | ||
| 426 | |||
| 427 | it.effect("leaves the root alone off Windows", () => | ||
| 428 | Effect.gen(function* () { | ||
| 429 | expect(yield* museWorkspaceRoot("/workspace/repo")).toBe("/workspace/repo"); | ||
| 430 | }).pipe(Effect.provideService(HostProcess.Platform, "linux")), | ||
| 431 | ); | ||
| 432 | |||
| 433 | it.effect("keeps a Windows root it cannot resolve, in verbatim form", () => | ||
| 434 | Effect.gen(function* () { | ||
| 435 | expect(yield* museWorkspaceRoot("C:\\definitely-missing\\repo")).toBe( | ||
| 436 | "\\\\?\\C:\\definitely-missing\\repo", | ||
| 437 | ); | ||
| 438 | // `/` would be taken literally inside a verbatim path. | ||
| 439 | expect(yield* museWorkspaceRoot("C:/definitely-missing/repo")).toBe( | ||
| 440 | "\\\\?\\C:\\definitely-missing\\repo", | ||
| 441 | ); | ||
| 442 | expect(yield* museWorkspaceRoot("//server/share/missing")).toBe( | ||
| 443 | "\\\\?\\UNC\\server\\share\\missing", | ||
| 444 | ); | ||
| 445 | }).pipe(Effect.provideService(HostProcess.Platform, "win32")), | ||
| 446 | ); | ||
| 447 | |||
| 448 | // FileSystem.realPath keeps the case it is given; Muse rejects a root whose case differs from the disk's. | ||
| 449 | it.effect("fixes the case of a Windows root natively", () => | ||
| 450 | Effect.gen(function* () { | ||
| 451 | if ((yield* HostProcess.Platform) !== "win32") return; | ||
| 452 | const fs = yield* FileSystem.FileSystem; | ||
| 453 | const path = yield* Path.Path; | ||
| 454 | const created = yield* fs.makeTempDirectoryScoped({ prefix: "MuseRoot-" }); | ||
| 455 | const root = yield* museWorkspaceRoot(created.toLowerCase()); | ||
| 456 | expect(root.startsWith("\\\\?\\")).toBe(true); | ||
| 457 | expect(root.endsWith(`\\${path.basename(created)}`)).toBe(true); | ||
| 458 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 459 | ); | ||
| 460 | }); |
packages/provider-muse/src/server/sdk.ts+83-3| ... | @@ -1,5 +1,8 @@ | ... | @@ -1,5 +1,8 @@ |
| 1 | // @effect-diagnostics globalTimers:off | 1 | // @effect-diagnostics globalTimers:off |
| 2 | // The SDK owns process shutdown; native deadlines cover initialize before an Effect resource exists. | 2 | // The SDK owns process shutdown; native deadlines cover initialize before an Effect resource exists. |
| 3 | // @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.realPath is Node's JS realpath; museWorkspaceRoot needs the native one. | ||
| 4 | import * as NodeFSP from "node:fs/promises"; | ||
| 5 | |||
| 3 | import { | 6 | import { |
| 4 | spawnMspConnection, | 7 | spawnMspConnection, |
| 5 | type Connection, | 8 | type Connection, |
| ... | @@ -7,6 +10,8 @@ import { | ... | @@ -7,6 +10,8 @@ import { |
| 7 | type SpawnedMspConnection, | 10 | type SpawnedMspConnection, |
| 8 | } from "@muse-code/sdk"; | 11 | } from "@muse-code/sdk"; |
| 9 | import type { RuntimeMode } from "@t3tools/contracts"; | 12 | import type { RuntimeMode } from "@t3tools/contracts"; |
| 13 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 14 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; | ||
| 10 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| 11 | 16 | ||
| 12 | export interface MuseSdkHost { | 17 | export interface MuseSdkHost { |
| ... | @@ -27,10 +32,18 @@ export interface MuseSdkHost { | ... | @@ -27,10 +32,18 @@ export interface MuseSdkHost { |
| 27 | readonly close: () => Promise<void>; | 32 | readonly close: () => Promise<void>; |
| 28 | } | 33 | } |
| 29 | 34 | ||
| 35 | /** The program that runs `binaryPath`, and the arguments that go before the binary's own. */ | ||
| 36 | export interface MuseLaunch { | ||
| 37 | readonly command: string; | ||
| 38 | readonly args: ReadonlyArray<string>; | ||
| 39 | } | ||
| 40 | |||
| 30 | export interface MuseSdkHostOptions { | 41 | export interface MuseSdkHostOptions { |
| 31 | readonly binaryPath: string; | 42 | readonly binaryPath: string; |
| 32 | /** Arguments before the serve arguments, for a launcher in front of the binary. */ | 43 | /** Arguments before the serve arguments, for a launcher in front of the binary. */ |
| 33 | readonly launchArgs?: ReadonlyArray<string>; | 44 | readonly launchArgs?: ReadonlyArray<string>; |
| 45 | /** How to start `binaryPath`, from `museLaunch`; without it the binary path is run as is. */ | ||
| 46 | readonly launch?: MuseLaunch; | ||
| 34 | readonly cwd?: string; | 47 | readonly cwd?: string; |
| 35 | readonly environment?: NodeJS.ProcessEnv; | 48 | readonly environment?: NodeJS.ProcessEnv; |
| 36 | readonly runtimeMode?: RuntimeMode; | 49 | readonly runtimeMode?: RuntimeMode; |
| ... | @@ -56,6 +69,71 @@ export function makeMuseEnvironment( | ... | @@ -56,6 +69,71 @@ export function makeMuseEnvironment( |
| 56 | }; | 69 | }; |
| 57 | } | 70 | } |
| 58 | 71 | ||
| 72 | /** Windows' verbatim form of a canonical path: `\\?\C:\…`, or `\\?\UNC\server\share\…`. */ | ||
| 73 | export function museVerbatimPath(path: string): string { | ||
| 74 | if (path.startsWith("\\\\?\\")) return path; | ||
| 75 | return path.startsWith("\\\\") ? `\\\\?\\UNC\\${path.slice(2)}` : `\\\\?\\${path}`; | ||
| 76 | } | ||
| 77 | |||
| 78 | /** | ||
| 79 | * A workspace root in the form `turn/start` accepts. On Windows Muse rejects | ||
| 80 | * anything but the verbatim canonical path, true case and long names | ||
| 81 | * included, so resolve with the native realpath, which unlike | ||
| 82 | * FileSystem.realPath fixes case and expands 8.3 names. Muse itself still | ||
| 83 | * starts in the plain path: the `muse.cmd` launcher runs under cmd.exe, which | ||
| 84 | * cannot use a verbatim directory. | ||
| 85 | */ | ||
| 86 | export const museWorkspaceRoot = Effect.fn("museWorkspaceRoot")(function* (path: string) { | ||
| 87 | if ((yield* HostProcess.Platform) !== "win32") return path; | ||
| 88 | const canonical = yield* Effect.tryPromise(() => NodeFSP.realpath(path)).pipe( | ||
| 89 | // A verbatim path takes `/` literally, so an unresolved root needs Windows separators. | ||
| 90 | Effect.orElseSucceed(() => path.replaceAll("/", "\\")), | ||
| 91 | ); | ||
| 92 | return museVerbatimPath(canonical); | ||
| 93 | }); | ||
| 94 | |||
| 95 | /** A Windows environment variable; their names are case-insensitive there. */ | ||
| 96 | function windowsEnvironmentValue(environment: NodeJS.ProcessEnv, name: string) { | ||
| 97 | const key = Object.keys(environment).find( | ||
| 98 | (candidate) => candidate.toUpperCase() === name.toUpperCase(), | ||
| 99 | ); | ||
| 100 | return key === undefined ? undefined : environment[key]; | ||
| 101 | } | ||
| 102 | |||
| 103 | /** | ||
| 104 | * How to start Muse. The SDK spawns without a shell, which finds the `muse` | ||
| 105 | * launcher on PATH everywhere but Windows. There Node neither looks a bare | ||
| 106 | * `muse` up with PATHEXT (ENOENT) nor runs a `.cmd` file such as Muse's own | ||
| 107 | * launcher (EINVAL) without a shell, so resolve the binary like any other | ||
| 108 | * command and run a `.cmd` or `.bat` under cmd.exe. Node quotes a path with | ||
| 109 | * spaces, and inside those quotes cmd takes every character literally. A path | ||
| 110 | * without spaces is not quoted, so its metacharacters are escaped instead. | ||
| 111 | */ | ||
| 112 | export const museLaunch = Effect.fn("museLaunch")(function* ( | ||
| 113 | binaryPath: string, | ||
| 114 | environment?: NodeJS.ProcessEnv, | ||
| 115 | ): Effect.fn.Return<MuseLaunch> { | ||
| 116 | if ((yield* HostProcess.Platform) !== "win32") return { command: binaryPath, args: [] }; | ||
| 117 | const env = environment ?? (yield* HostProcess.Environment); | ||
| 118 | const resolveExecutable = yield* SpawnExecutableResolution; | ||
| 119 | const resolved = resolveExecutable(binaryPath, "win32", env) ?? binaryPath; | ||
| 120 | if (!/\.(?:cmd|bat)$/i.test(resolved)) return { command: resolved, args: [] }; | ||
| 121 | const systemRoot = windowsEnvironmentValue(env, "SystemRoot"); | ||
| 122 | return { | ||
| 123 | command: | ||
| 124 | windowsEnvironmentValue(env, "ComSpec") ?? | ||
| 125 | (systemRoot ? `${systemRoot}\\System32\\cmd.exe` : "cmd.exe"), | ||
| 126 | // /d skips cmd's AutoRun commands, which could write into Muse's stdout. cmd /c drops | ||
| 127 | // the quotes from a command that starts with one and holds `&`, `@`, `^` or a | ||
| 128 | // parenthesis, so a quoted path follows a bare `@` (echo off), which keeps them. | ||
| 129 | args: [ | ||
| 130 | "/d", | ||
| 131 | "/c", | ||
| 132 | ...(/\s/.test(resolved) ? ["@", resolved] : [resolved.replace(/[()&<>@^|]/g, "^$&")]), | ||
| 133 | ], | ||
| 134 | }; | ||
| 135 | }); | ||
| 136 | |||
| 59 | export function museApprovalMode(runtimeMode: RuntimeMode) { | 137 | export function museApprovalMode(runtimeMode: RuntimeMode) { |
| 60 | return runtimeMode === "full-access" ? "allowAll" : "promptUnmatched"; | 138 | return runtimeMode === "full-access" ? "allowAll" : "promptUnmatched"; |
| 61 | } | 139 | } |
| ... | @@ -89,9 +167,10 @@ export async function createMuseSdkHost( | ... | @@ -89,9 +167,10 @@ export async function createMuseSdkHost( |
| 89 | spawn: typeof spawnMspConnection = spawnMspConnection, | 167 | spawn: typeof spawnMspConnection = spawnMspConnection, |
| 90 | ): Promise<MuseSdkHost> { | 168 | ): Promise<MuseSdkHost> { |
| 91 | options.signal?.throwIfAborted(); | 169 | options.signal?.throwIfAborted(); |
| 170 | const launch = options.launch ?? { command: options.binaryPath, args: [] }; | ||
| 92 | const handshake = spawn({ | 171 | const handshake = spawn({ |
| 93 | command: options.binaryPath, | 172 | command: launch.command, |
| 94 | args: [...(options.launchArgs ?? []), ...museServeArgs(options)], | 173 | args: [...launch.args, ...(options.launchArgs ?? []), ...museServeArgs(options)], |
| 95 | ...(options.cwd ? { cwd: options.cwd } : {}), | 174 | ...(options.cwd ? { cwd: options.cwd } : {}), |
| 96 | // Callers pass an environment already built with makeMuseEnvironment. | 175 | // Callers pass an environment already built with makeMuseEnvironment. |
| 97 | env: options.environment ?? makeMuseEnvironment(), | 176 | env: options.environment ?? makeMuseEnvironment(), |
| ... | @@ -148,9 +227,10 @@ export const createMuseSdkHostEffect = Effect.fn("createMuseSdkHostEffect")(func | ... | @@ -148,9 +227,10 @@ export const createMuseSdkHostEffect = Effect.fn("createMuseSdkHostEffect")(func |
| 148 | options: Omit<MuseSdkHostOptions, "signal">, | 227 | options: Omit<MuseSdkHostOptions, "signal">, |
| 149 | createHost: typeof createMuseSdkHost = createMuseSdkHost, | 228 | createHost: typeof createMuseSdkHost = createMuseSdkHost, |
| 150 | ) { | 229 | ) { |
| 230 | const launch = options.launch ?? (yield* museLaunch(options.binaryPath, options.environment)); | ||
| 151 | let startup: Promise<MuseSdkHost> | undefined; | 231 | let startup: Promise<MuseSdkHost> | undefined; |
| 152 | return yield* Effect.tryPromise((signal) => { | 232 | return yield* Effect.tryPromise((signal) => { |
| 153 | startup = createHost({ ...options, signal }); | 233 | startup = createHost({ ...options, launch, signal }); |
| 154 | return startup; | 234 | return startup; |
| 155 | }).pipe( | 235 | }).pipe( |
| 156 | Effect.onInterrupt(() => | 236 | Effect.onInterrupt(() => |
packages/provider-muse/src/testing.ts+5-2| ... | @@ -1,13 +1,16 @@ | ... | @@ -1,13 +1,16 @@ |
| 1 | /** | 1 | /** |
| 2 | * Muse Code internals for replay tests and the fixture recorder: the MSP | 2 | * Muse Code internals for replay tests and the fixture recorder: how Muse is |
| 3 | * launch arguments, initialize parameters, environment, and version parser. | 3 | * started, the MSP launch arguments, initialize parameters, environment, the |
| 4 | * workspace root `turn/start` sends, and version parser. | ||
| 4 | * | 5 | * |
| 5 | * @module provider-muse/testing | 6 | * @module provider-muse/testing |
| 6 | */ | 7 | */ |
| 7 | export { | 8 | export { |
| 8 | makeMuseEnvironment, | 9 | makeMuseEnvironment, |
| 9 | museInitializeParams, | 10 | museInitializeParams, |
| 11 | museLaunch, | ||
| 10 | museServeArgs, | 12 | museServeArgs, |
| 13 | museWorkspaceRoot, | ||
| 11 | type MuseSdkHost, | 14 | type MuseSdkHost, |
| 12 | type MuseSdkHostOptions, | 15 | type MuseSdkHostOptions, |
| 13 | } from "./server/sdk.ts"; | 16 | } from "./server/sdk.ts"; |