diff --git a/.github/workflows/release-mtm-connect.yml b/.github/workflows/release-mtm-connect.yml new file mode 100644 index 0000000..dad63b7 --- /dev/null +++ b/.github/workflows/release-mtm-connect.yml @@ -0,0 +1,135 @@ +name: release-mtm-connect +run-name: Release mtm-connect ${{ github.ref_name }} + +on: + push: + tags: + - 'mtm-connect-v[0-9]*.[0-9]*.[0-9]*' + +permissions: + contents: read + id-token: write + +concurrency: + group: mtm-connect-npm-publish + cancel-in-progress: false + +env: + PACKAGE_NAME: mtm-connect + PACKAGE_DIR: packages/mtm-connect + TAG_PREFIX: mtm-connect-v + +jobs: + publish: + name: Pack, publish, and read back mtm-connect + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout tagged main commit + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.7.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.19.0' + registry-url: https://registry.npmjs.org + cache: pnpm + + - name: Enable Corepack + run: corepack enable + + - name: Verify pnpm version + run: test "$(pnpm --version)" = 11.7.0 + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Validate tag and main ancestry + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + package_version="$(node -p "require('./${PACKAGE_DIR}/package.json').version")" + test "$RELEASE_TAG" = "${TAG_PREFIX}${package_version}" + git fetch --no-tags origin main + git merge-base --is-ancestor "$GITHUB_SHA" origin/main + printf 'PACKAGE_VERSION=%s\n' "$package_version" >> "$GITHUB_ENV" + + - name: Check browser extension and manifest pin + run: | + set -euo pipefail + pnpm --filter mtm-connect run check + package_version="$(node -p "require('./${PACKAGE_DIR}/package.json').version")" + manifest_version="$(sed -n '/id: "mtm-connect"/{n;s/.*version: "\([^\"]*\)".*/\1/p;}' packages/mtmharness/src/features/secondary/manifest.ts)" + expected_integrity="$(sed -n '/id: "mtm-connect"/{n;n;n;s/.*clientIntegrity: "\([^\"]*\)".*/\1/p;}' packages/mtmharness/src/features/secondary/manifest.ts)" + test "$manifest_version" = "$package_version" + local_integrity="sha256-$(openssl dgst -sha256 -binary packages/mtm-connect/lib/client.js | base64 -w0)" + test "$local_integrity" = "$expected_integrity" + printf 'EXPECTED_INTEGRITY=%s\n' "$expected_integrity" >> "$GITHUB_ENV" + + - name: Pack release tarball + id: pack + run: | + set -euo pipefail + pack_dir="$RUNNER_TEMP/mtm-connect" + mkdir -p "$pack_dir" + pnpm --filter mtm-connect pack --pack-destination "$pack_dir" + tarball="$(find "$pack_dir" -maxdepth 1 -type f -name "${PACKAGE_NAME}-*.tgz" -print -quit)" + test -n "$tarball" + local_integrity="sha512-$(openssl dgst -sha512 -binary "$tarball" | base64 -w0)" + printf 'tarball=%s\n' "$tarball" >> "$GITHUB_OUTPUT" + printf 'LOCAL_INTEGRITY=%s\n' "$local_integrity" >> "$GITHUB_ENV" + + - name: Lint package metadata + run: pnpm dlx publint run "${{ steps.pack.outputs.tarball }}" --strict + + - name: Preflight registry and token + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + test -n "${NODE_AUTH_TOKEN:-}" + npm whoami --registry=https://registry.npmjs.org >/dev/null + status="$(curl --silent --show-error --location --output /dev/null --write-out '%{http_code}' "https://registry.npmjs.org/${PACKAGE_NAME}/${PACKAGE_VERSION}")" + test "$status" = 404 + + - name: Publish with provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish "${{ steps.pack.outputs.tarball }}" --access public --provenance --ignore-scripts + + - name: Read back npm and CDN artifacts + run: | + set -euo pipefail + remote="$RUNNER_TEMP/mtm-connect-client.js" + for attempt in $(seq 1 12); do + published_integrity="$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" dist.integrity --json 2>/dev/null | jq -r . || true)" + published_version="$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version --json 2>/dev/null | jq -r . || true)" + curl --fail --silent --show-error --location --output "$remote" "https://unpkg.com/${PACKAGE_NAME}@${PACKAGE_VERSION}/lib/client.js" || true + remote_integrity="" + test -f "$remote" && remote_integrity="sha256-$(openssl dgst -sha256 -binary "$remote" | base64 -w0)" + if test "$published_integrity" = "$LOCAL_INTEGRITY" && test "$published_version" = "$PACKAGE_VERSION" && test "$remote_integrity" = "$EXPECTED_INTEGRITY" && cmp packages/mtm-connect/lib/client.js "$remote"; then + break + fi + if test "$attempt" -eq 12; then + echo "npm/CDN artifact read-back did not converge after ${attempt} attempts" >&2 + exit 1 + fi + sleep 5 + done + { + echo '### npm release evidence' + echo + echo "- package: ${PACKAGE_NAME}@${PACKAGE_VERSION}" + echo "- tag: ${GITHUB_REF_NAME}" + echo "- integrity: ${published_integrity}" + echo '- provenance: requested with npm publish --provenance' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-mtmharness.yml b/.github/workflows/release-mtmharness.yml index 0adcfb7..c37ee40 100644 --- a/.github/workflows/release-mtmharness.yml +++ b/.github/workflows/release-mtmharness.yml @@ -68,18 +68,30 @@ jobs: set -euo pipefail pnpm --filter mtmharness run check pnpm --filter mtmcanvas run check - # The pinned Canvas artifact must already be published before mtmharness. + pnpm --filter mtm-connect run check + # The pinned secondary artifacts must already be published before mtmharness. canvas_version="$(node -p "require('./packages/mtmcanvas/package.json').version")" - expected_integrity="$(sed -n 's/.*clientIntegrity: \"\([^\"]*\)\".*/\1/p' packages/mtmharness/src/features/secondary/manifest.ts)" + canvas_integrity="$(sed -n '/id: "mtmcanvas"/{n;n;n;s/.*clientIntegrity: "\([^\"]*\)".*/\1/p;}' packages/mtmharness/src/features/secondary/manifest.ts)" local_integrity="sha256-$(openssl dgst -sha256 -binary packages/mtmcanvas/lib/client.js | base64 -w0)" - test -n "$expected_integrity" - test "$local_integrity" = "$expected_integrity" + test -n "$canvas_integrity" + test "$local_integrity" = "$canvas_integrity" test "$(npm view "mtmcanvas@${canvas_version}" version --json | jq -r .)" = "$canvas_version" remote="$RUNNER_TEMP/mtmcanvas-client.js" curl --fail --silent --show-error --output "$remote" "https://unpkg.com/mtmcanvas@${canvas_version}/lib/client.js" remote_integrity="sha256-$(openssl dgst -sha256 -binary "$remote" | base64 -w0)" - test "$remote_integrity" = "$expected_integrity" + test "$remote_integrity" = "$canvas_integrity" cmp "packages/mtmcanvas/lib/client.js" "$remote" + connect_version="$(node -p "require('./packages/mtm-connect/package.json').version")" + connect_integrity="$(sed -n '/id: "mtm-connect"/{n;n;n;s/.*clientIntegrity: "\([^\"]*\)".*/\1/p;}' packages/mtmharness/src/features/secondary/manifest.ts)" + local_integrity="sha256-$(openssl dgst -sha256 -binary packages/mtm-connect/lib/client.js | base64 -w0)" + test -n "$connect_integrity" + test "$local_integrity" = "$connect_integrity" + test "$(npm view "mtm-connect@${connect_version}" version --json | jq -r .)" = "$connect_version" + remote="$RUNNER_TEMP/mtm-connect-client.js" + curl --fail --silent --show-error --output "$remote" "https://unpkg.com/mtm-connect@${connect_version}/lib/client.js" + remote_integrity="sha256-$(openssl dgst -sha256 -binary "$remote" | base64 -w0)" + test "$remote_integrity" = "$connect_integrity" + cmp "packages/mtm-connect/lib/client.js" "$remote" - name: Pack release tarball id: pack diff --git a/package.json b/package.json index 8d4c283..75d2045 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "pnpm --filter mtmharness run build", "demo": "pnpm --filter mtmharness run dev", - "check": "pnpm --filter mtmharness run check && pnpm --filter mtmcanvas run check", + "check": "pnpm --filter mtmharness run check && pnpm --filter mtmcanvas run check && pnpm --filter mtm-connect run check", "pack:mtmharness": "pnpm --filter mtmharness run pack:check" }, "devDependencies": { "typescript": "6.0.3" } diff --git a/packages/mtm-connect/LICENSE b/packages/mtm-connect/LICENSE new file mode 100644 index 0000000..9ee7bee --- /dev/null +++ b/packages/mtm-connect/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 codeh007 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mtm-connect/README.md b/packages/mtm-connect/README.md new file mode 100644 index 0000000..71873ec --- /dev/null +++ b/packages/mtm-connect/README.md @@ -0,0 +1,13 @@ +# mtm-connect + +Browser-only mock frontend extension for mtmharness. + +The package is not a standard DSH plugin. It publishes one self-contained native ESM artifact at lib/client.js, exporting mount(context). mtmharness owns runtime loading, exact version, SHA-256 integrity, enable/disable setting, and cleanup. + +The current UI models device and execution-world connections in memory. It supports selecting a connection, refreshing mock state, and toggling a connection online or offline. No backend, filesystem, token, or DSH service is required by the artifact. + +## Development + + pnpm --filter mtm-connect run check + +Install mtmharness into the DSH Web profile. The MTM Connect setting loads this artifact at runtime; do not install mtm-connect with dsh plugin. diff --git a/packages/mtm-connect/package.json b/packages/mtm-connect/package.json new file mode 100644 index 0000000..243c7d3 --- /dev/null +++ b/packages/mtm-connect/package.json @@ -0,0 +1,67 @@ +{ + "name": "mtm-connect", + "version": "0.2.0", + "description": "Browser-only mock device connection frontend extension for mtmharness.", + "type": "module", + "engines": { + "node": ">=22.19.0", + "pnpm": ">=11.7.0" + }, + "main": "./lib/client.js", + "types": "./lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "import": "./lib/client.js", + "default": "./lib/client.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "import": "./lib/client.js", + "default": "./lib/client.js" + }, + "./package.json": "./package.json" + }, + "mtmharness": { + "secondary": { + "id": "mtm-connect", + "apiVersion": 1, + "client": "./lib/client.js" + } + }, + "files": [ + "lib/client.js", + "lib/types/**/*.d.ts", + "README.md", + "package.json", + "LICENSE" + ], + "scripts": { + "build": "node scripts/build.mjs", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "check": "pnpm run typecheck && pnpm run test && pnpm run build && pnpm run pack:check", + "pack:check": "node scripts/pack-check.mjs", + "prepack": "pnpm run build" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codeh007/mtmdsh.git", + "directory": "packages/mtm-connect" + }, + "devDependencies": { + "@types/node": "22.20.1", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", + "esbuild": "0.28.2", + "jsdom": "30.0.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "typescript": "6.0.3", + "vitest": "4.1.11" + } +} \ No newline at end of file diff --git a/packages/mtm-connect/scripts/build.mjs b/packages/mtm-connect/scripts/build.mjs new file mode 100644 index 0000000..ba5b5ed --- /dev/null +++ b/packages/mtm-connect/scripts/build.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const libRoot = resolve(packageRoot, "lib"); +const tsc = resolve(packageRoot, "node_modules/.bin/tsc"); + +rmSync(libRoot, { recursive: true, force: true }); +mkdirSync(libRoot, { recursive: true }); +if (!existsSync(tsc)) throw new Error("mtm-connect build: local TypeScript executable is missing"); +execFileSync(tsc, ["--project", resolve(packageRoot, "tsconfig.json")], { cwd: packageRoot, stdio: "inherit" }); + +await build({ + entryPoints: [resolve(packageRoot, "src/client/index.ts")], + outfile: resolve(libRoot, "client.js"), + bundle: true, + format: "esm", + platform: "browser", + target: "es2022", + legalComments: "none", + logLevel: "info", +}); +const artifact = await import(resolve(libRoot, "client.js")); +if (typeof artifact.mount !== "function") throw new Error("mtm-connect build: client artifact must export mount(context)"); + +console.log("built mtm-connect browser ESM artifact"); diff --git a/packages/mtm-connect/scripts/pack-check.mjs b/packages/mtm-connect/scripts/pack-check.mjs new file mode 100644 index 0000000..98917a7 --- /dev/null +++ b/packages/mtm-connect/scripts/pack-check.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const destination = mkdtempSync(join(tmpdir(), "mtm-connect-pack-")); +try { + execFileSync("pnpm", ["pack", "--pack-destination", destination], { cwd: packageRoot, stdio: "inherit" }); + const tarball = readdirSync(destination).find((name) => name.endsWith(".tgz")); + if (tarball === undefined) throw new Error("mtm-connect pack: pnpm did not create a tarball"); + const entries = execFileSync("tar", ["-tzf", join(destination, tarball)], { encoding: "utf8" }).split("\n"); + for (const entry of ["package/LICENSE", "package/README.md", "package/package.json", "package/lib/client.js", "package/lib/types/index.d.ts", "package/lib/types/client/index.d.ts"]) { + if (!entries.includes(entry)) throw new Error("mtm-connect pack: missing " + entry); + } + if (entries.some((entry) => entry.includes(".test."))) throw new Error("mtm-connect pack: test files must not ship"); + const source = readFileSync(join(packageRoot, "lib/client.js"), "utf8"); + for (const forbidden of [/^\s*import\s/m, /require\(["']node:/, /require\(["'](?:fs|child_process)/, "@deepseek-ai/", "__ModuleLoader__"]) { + if (forbidden instanceof RegExp ? forbidden.test(source) : source.includes(forbidden)) throw new Error("mtm-connect pack: client artifact is not self-contained: " + forbidden); + } + if (!source.includes("export {")) throw new Error("mtm-connect pack: client artifact does not export mount"); +} finally { + rmSync(destination, { recursive: true, force: true }); +} diff --git a/packages/mtm-connect/src/client/ConnectView.tsx b/packages/mtm-connect/src/client/ConnectView.tsx new file mode 100644 index 0000000..c210994 --- /dev/null +++ b/packages/mtm-connect/src/client/ConnectView.tsx @@ -0,0 +1,85 @@ +import type { ReactElement } from "react"; +import type { ConnectActions, ConnectViewState, MockConnection } from "./runtime.ts"; + +function statusLabel(status: MockConnection["status"]): string { + return status === "online" ? "Online" : "Offline"; +} + +function Status({ status }: { status: MockConnection["status"] }): ReactElement { + return {statusLabel(status)}; +} + +function selectedConnection(state: ConnectViewState): MockConnection | undefined { + return state.connections.find((connection) => connection.id === state.selectedId) ?? state.connections[0]; +} + +export function ConnectView({ state, actions, onClose }: { state: ConnectViewState; actions: ConnectActions; onClose: () => void }): ReactElement { + const selected = selectedConnection(state); + const online = state.connections.filter((connection) => connection.status === "online").length; + return ( +
+
+
+ MTM Connect +

Device connections

+

Mock backend

+
+ +
+
+
{state.connections.length}Connections
+
{online}Online
+
{state.connections.reduce((total, connection) => total + connection.capabilities.length, 0)}Capabilities
+
+
+
+
+

Connections

+ +
+
+ {state.connections.map((connection) => ( + + ))} +
+
+
+ {selected === undefined ?

No connections.

: ( + <> +
+
+

{selected.label}

+

{selected.target}

+
+ +
+
+
Generation
{selected.generation}
+
Latency
{selected.latencyMs === 0 ? "-" : selected.latencyMs + " ms"}
+
Last seen
{new Date(selected.lastSeen).toLocaleTimeString()}
+
+

Capabilities

+
    + {selected.capabilities.map((capability) =>
  • {capability}
  • )} +
+ + + )} +
+
+ {state.notice !== undefined ?
{state.notice}
: null} + {state.error !== undefined ?
{state.error}
: null} +
+ ); +} diff --git a/packages/mtm-connect/src/client/index.test.tsx b/packages/mtm-connect/src/client/index.test.tsx new file mode 100644 index 0000000..4a716cb --- /dev/null +++ b/packages/mtm-connect/src/client/index.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { mount, type MtmharnessFrontendExtensionContext } from "./index.ts"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement | undefined; +let cleanup: (() => void) | undefined; + +afterEach(async () => { + await act(async () => { cleanup?.(); }); + container?.remove(); + container = undefined; + cleanup = undefined; +}); + +function context(root: HTMLElement): MtmharnessFrontendExtensionContext { + const controller = new AbortController(); + return { + apiVersion: 1, + id: "mtm-connect", + version: "0.2.0", + root, + document, + signal: controller.signal, + registerCleanup: () => {}, + }; +} + +describe("mtm-connect frontend extension", () => { + it("mounts a usable mock panel and cleans up its resources", async () => { + container = document.createElement("div"); + container.setAttribute("style", "color: red;"); + container.setAttribute("tabindex", "0"); + document.body.append(container); + await act(async () => { + cleanup = mount(context(container!)); + await Promise.resolve(); + }); + expect(container.textContent).toContain("Device connections"); + expect(container.textContent).toContain("Android device"); + + const disconnect = [...container.querySelectorAll("button")].find((button) => button.textContent === "Disconnect"); + expect(disconnect).toBeDefined(); + await act(async () => { disconnect?.click(); await Promise.resolve(); }); + expect(container.textContent).toContain("Android device disconnected"); + + const close = container.querySelector('button[aria-label="Close MTM Connect"]'); + await act(async () => { close?.click(); await Promise.resolve(); }); + expect(container.hidden).toBe(true); + await act(async () => { cleanup?.(); await Promise.resolve(); }); + expect(container.querySelector(".mtm-connect-view")).toBeNull(); + expect(container.getAttribute("style")).toBe("color: red;"); + expect(container.getAttribute("tabindex")).toBe("0"); + expect(container.hidden).toBe(false); + expect(document.head.querySelector('style[data-mtm-secondary-extension="mtm-connect"]')).toBeNull(); + }); +}); diff --git a/packages/mtm-connect/src/client/index.ts b/packages/mtm-connect/src/client/index.ts new file mode 100644 index 0000000..ad69276 --- /dev/null +++ b/packages/mtm-connect/src/client/index.ts @@ -0,0 +1,79 @@ +import { createElement, useSyncExternalStore } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { ConnectView } from "./ConnectView.tsx"; +import { ConnectRuntime } from "./runtime.ts"; +import { MTM_CONNECT_CSS } from "./styles.ts"; + +export interface MtmharnessFrontendExtensionContext { + readonly apiVersion: 1; + readonly id: string; + readonly version: string; + readonly root: HTMLElement; + readonly document: Document; + readonly signal: AbortSignal; + readonly registerCleanup: (cleanup: () => void | Promise) => void; +} + +export type MtmharnessFrontendExtensionCleanup = void | (() => void | Promise); + +function ConnectExtension({ runtime, onClose }: { runtime: ConnectRuntime; onClose: () => void }) { + const state = useSyncExternalStore(runtime.subscribe, runtime.getSnapshot, runtime.getSnapshot); + return createElement(ConnectView, { state, actions: runtime, onClose }); +} + +/** Mount the browser-only mock Connect view through the mtmharness ABI. */ +export function mount(context: MtmharnessFrontendExtensionContext): () => void { + const runtime = new ConnectRuntime(); + let reactRoot: ReturnType | undefined; + let style: HTMLStyleElement | undefined; + const previousStyle = context.root.getAttribute("style"); + const previousTabIndex = context.root.getAttribute("tabindex"); + const previousHidden = context.root.hidden; + let disposed = false; + const restoreRoot = (): void => { + if (previousStyle === null) context.root.removeAttribute("style"); + else context.root.setAttribute("style", previousStyle); + if (previousTabIndex === null) context.root.removeAttribute("tabindex"); + else context.root.setAttribute("tabindex", previousTabIndex); + context.root.hidden = previousHidden; + }; + const dispose = (): void => { + if (disposed) return; + disposed = true; + context.signal.removeEventListener("abort", dispose); + runtime.dispose(); + reactRoot?.unmount(); + style?.remove(); + restoreRoot(); + }; + context.registerCleanup(dispose); + try { + context.root.tabIndex = -1; + context.root.style.position = "fixed"; + context.root.style.top = "16px"; + context.root.style.right = "16px"; + context.root.style.bottom = "16px"; + context.root.style.zIndex = "1000"; + context.root.style.width = "min(520px, calc(100vw - 32px))"; + context.root.style.overflow = "hidden"; + context.root.style.border = "1px solid #cbd5e1"; + context.root.style.borderRadius = "8px"; + context.root.style.background = "#f7f8fa"; + context.root.style.boxShadow = "0 16px 40px #17203333"; + style = context.document.createElement("style"); + style.dataset.mtmSecondaryExtension = context.id; + style.textContent = MTM_CONNECT_CSS; + context.document.head.append(style); + const root = createRoot(context.root); + reactRoot = root; + context.signal.addEventListener("abort", dispose, { once: true }); + flushSync(() => { + root.render(createElement(ConnectExtension, { runtime, onClose: () => { context.root.hidden = true; } })); + }); + return dispose; + } catch (error) { + dispose(); + throw error; + } +} diff --git a/packages/mtm-connect/src/client/runtime.test.ts b/packages/mtm-connect/src/client/runtime.test.ts new file mode 100644 index 0000000..7b04d6d --- /dev/null +++ b/packages/mtm-connect/src/client/runtime.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { ConnectRuntime } from "./runtime.ts"; + +describe("mtm-connect mock runtime", () => { + it("starts with a selectable online device and offline workstation", () => { + const runtime = new ConnectRuntime(() => 1_700_000_000_000); + expect(runtime.getSnapshot()).toMatchObject({ + selectedId: "mock-android", + notice: "Mock backend", + connections: [ + { id: "mock-android", status: "online", generation: 1 }, + { id: "mock-workstation", status: "offline", generation: 0 }, + ], + }); + }); + + it("toggles a connection and advances its generation", () => { + const runtime = new ConnectRuntime(() => 1_700_000_000_000); + runtime.toggle("mock-workstation"); + expect(runtime.getSnapshot().connections[1]).toMatchObject({ status: "online", generation: 1, latencyMs: 82 }); + runtime.toggle("mock-workstation"); + expect(runtime.getSnapshot().connections[1]).toMatchObject({ status: "offline", generation: 2, latencyMs: 0 }); + }); + + it("reports unknown selections without changing the selected connection", () => { + const runtime = new ConnectRuntime(); + runtime.select("missing"); + expect(runtime.getSnapshot()).toMatchObject({ selectedId: "mock-android", error: "Connection was not found" }); + }); + + it("stops publishing after disposal", () => { + const runtime = new ConnectRuntime(); + let updates = 0; + runtime.subscribe(() => { updates += 1; }); + runtime.dispose(); + runtime.refresh(); + expect(updates).toBe(0); + }); +}); diff --git a/packages/mtm-connect/src/client/runtime.ts b/packages/mtm-connect/src/client/runtime.ts new file mode 100644 index 0000000..6019bbc --- /dev/null +++ b/packages/mtm-connect/src/client/runtime.ts @@ -0,0 +1,124 @@ +export type ConnectStatus = "online" | "offline"; + +export interface MockConnection { + readonly id: string; + readonly label: string; + readonly target: string; + readonly status: ConnectStatus; + readonly generation: number; + readonly latencyMs: number; + readonly capabilities: readonly string[]; + readonly lastSeen: number; +} + +export interface ConnectViewState { + readonly connections: readonly MockConnection[]; + readonly selectedId: string; + readonly loading: boolean; + readonly notice?: string; + readonly error?: string; +} + +export interface ConnectActions { + refresh(): void; + select(id: string): void; + toggle(id: string): void; +} + +function initialConnections(now: number): MockConnection[] { + return [ + { + id: "mock-android", + label: "Android device", + target: "VMOS Cloud sandbox", + status: "online", + generation: 1, + latencyMs: 82, + capabilities: ["screen", "input", "files"], + lastSeen: now, + }, + { + id: "mock-workstation", + label: "Workstation", + target: "Mock execution world", + status: "offline", + generation: 0, + latencyMs: 0, + capabilities: ["shell", "files"], + lastSeen: now, + }, + ]; +} + +/** In-memory device and execution-world state for the first mtm-connect release. */ +export class ConnectRuntime implements ConnectActions { + private view: ConnectViewState; + private readonly listeners = new Set<() => void>(); + private disposed = false; + + constructor(private readonly now: () => number = Date.now) { + const connections = initialConnections(now()); + this.view = { + connections, + selectedId: connections[0]!.id, + loading: false, + notice: "Mock backend", + }; + } + + getSnapshot = (): ConnectViewState => this.view; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + }; + + dispose(): void { + this.disposed = true; + this.listeners.clear(); + } + + refresh(): void { + if (this.disposed) return; + const lastSeen = this.now(); + this.set({ + connections: this.view.connections.map((connection) => ({ ...connection, lastSeen })), + loading: false, + notice: "Mock state refreshed", + error: undefined, + }); + } + + select(id: string): void { + if (this.view.connections.every((connection) => connection.id !== id)) { + this.set({ error: "Connection was not found" }); + return; + } + this.set({ selectedId: id, error: undefined }); + } + + toggle(id: string): void { + const connection = this.view.connections.find((candidate) => candidate.id === id); + if (connection === undefined) { + this.set({ error: "Connection was not found" }); + return; + } + const online = connection.status !== "online"; + const nextStatus: ConnectStatus = online ? "online" : "offline"; + const nextGeneration = connection.generation + 1; + this.set({ + connections: this.view.connections.map((candidate) => candidate.id === id + ? { ...candidate, status: nextStatus, generation: nextGeneration, latencyMs: online ? 82 : 0, lastSeen: this.now() } + : candidate), + selectedId: id, + notice: connection.label + (online ? " connected" : " disconnected"), + error: undefined, + }); + } + + private set(patch: Partial): void { + if (this.disposed) return; + this.view = { ...this.view, ...patch }; + for (const listener of [...this.listeners]) listener(); + } +} diff --git a/packages/mtm-connect/src/client/styles.ts b/packages/mtm-connect/src/client/styles.ts new file mode 100644 index 0000000..24cad54 --- /dev/null +++ b/packages/mtm-connect/src/client/styles.ts @@ -0,0 +1,42 @@ +export const MTM_CONNECT_CSS = + ".mtm-connect-view{box-sizing:border-box;display:flex;min-height:100%;flex-direction:column;background:#f7f8fa;color:#172033;font:13px/1.45 ui-sans-serif,system-ui,sans-serif}" + + ".mtm-connect-view *{box-sizing:border-box}" + + ".mtm-connect-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;border-bottom:1px solid #d9dee7;background:#fff;padding:18px 20px}" + + ".mtm-connect-kicker{display:block;color:#2563eb;font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}" + + ".mtm-connect-header h1{margin:2px 0 0;font-size:20px;line-height:1.2}" + + ".mtm-connect-header p{margin:6px 0 0;color:#667085}" + + ".mtm-connect-close,.mtm-connect-toolbar button,.mtm-connect-action{border:1px solid #cbd5e1;border-radius:4px;background:#fff;color:#172033;cursor:pointer;padding:7px 10px;font:inherit}" + + ".mtm-connect-close:hover,.mtm-connect-toolbar button:hover,.mtm-connect-action:hover:not(:disabled){background:#eff6ff;border-color:#93c5fd}" + + ".mtm-connect-close:focus-visible,.mtm-connect-toolbar button:focus-visible,.mtm-connect-action:focus-visible,.mtm-connect-list button:focus-visible{outline:2px solid #2563eb;outline-offset:2px}" + + ".mtm-connect-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;border-bottom:1px solid #d9dee7;background:#d9dee7}" + + ".mtm-connect-summary div{background:#fff;padding:12px 16px}" + + ".mtm-connect-summary strong,.mtm-connect-summary span{display:block}" + + ".mtm-connect-summary strong{font-size:18px}" + + ".mtm-connect-summary span{color:#667085;font-size:11px}" + + ".mtm-connect-body{display:grid;min-height:0;flex:1;grid-template-columns:minmax(170px,.85fr) minmax(0,1.15fr)}" + + ".mtm-connect-list,.mtm-connect-detail{min-width:0;padding:16px}" + + ".mtm-connect-list{border-right:1px solid #d9dee7;background:#fff}" + + ".mtm-connect-detail{background:#f7f8fa}" + + ".mtm-connect-toolbar,.mtm-connect-detail-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}" + + ".mtm-connect-toolbar{margin-bottom:10px}" + + ".mtm-connect-toolbar h2,.mtm-connect-detail h2{margin:0;font-size:14px}" + + ".mtm-connect-list-items{display:grid;gap:8px}" + + ".mtm-connect-list button{display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;border:1px solid #d9dee7;border-radius:4px;background:#fff;color:#172033;cursor:pointer;padding:11px;text-align:left}" + + ".mtm-connect-list button:hover,.mtm-connect-list button[aria-pressed=true]{border-color:#60a5fa;background:#eff6ff}" + + ".mtm-connect-list-copy{display:grid;min-width:0;gap:3px}" + + ".mtm-connect-list-copy strong,.mtm-connect-list-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}" + + ".mtm-connect-list-copy small,.mtm-connect-empty,.mtm-connect-detail p{color:#667085}" + + ".mtm-connect-status{flex:none;border-radius:999px;padding:3px 7px;background:#eef2f7;color:#667085;font-size:11px;font-weight:700}" + + ".mtm-connect-status-online{background:#dcfce7;color:#166534}" + + ".mtm-connect-detail-heading{align-items:flex-start}" + + ".mtm-connect-detail-heading p{margin:4px 0 0}" + + ".mtm-connect-meta{display:grid;gap:1px;margin:20px 0;border:1px solid #d9dee7;background:#d9dee7}" + + ".mtm-connect-meta div{display:flex;justify-content:space-between;gap:16px;background:#fff;padding:9px 11px}" + + ".mtm-connect-meta dt{color:#667085}" + + ".mtm-connect-meta dd{margin:0;text-align:right}" + + ".mtm-connect-capabilities{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 20px;padding:0;list-style:none}" + + ".mtm-connect-capabilities li{border:1px solid #cbd5e1;border-radius:4px;background:#fff;padding:4px 7px;color:#475467;font-size:11px}" + + ".mtm-connect-empty{padding:20px 0}" + + ".mtm-connect-notice{border-top:1px solid #d9dee7;background:#fff;padding:10px 16px;color:#475467;font-size:12px}" + + ".mtm-connect-error{border-top:1px solid #fecaca;background:#fff1f2;padding:10px 16px;color:#b42318;font-size:12px}" + + "@media(max-width:560px){.mtm-connect-body{grid-template-columns:1fr}.mtm-connect-list{border-right:0;border-bottom:1px solid #d9dee7}.mtm-connect-summary div{padding:10px}.mtm-connect-header{padding:14px}.mtm-connect-list,.mtm-connect-detail{padding:14px}}"; diff --git a/packages/mtm-connect/src/index.ts b/packages/mtm-connect/src/index.ts new file mode 100644 index 0000000..6ec2c89 --- /dev/null +++ b/packages/mtm-connect/src/index.ts @@ -0,0 +1,5 @@ +export { mount } from "./client/index.ts"; +export type { + MtmharnessFrontendExtensionCleanup, + MtmharnessFrontendExtensionContext, +} from "./client/index.ts"; diff --git a/packages/mtm-connect/tests/package-contract.test.ts b/packages/mtm-connect/tests/package-contract.test.ts new file mode 100644 index 0000000..f944487 --- /dev/null +++ b/packages/mtm-connect/tests/package-contract.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const packageRoot = resolve(import.meta.dirname, ".."); + +describe("mtm-connect secondary package contract", () => { + it("publishes only the mtmharness browser extension", () => { + const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")) as { + dsh?: unknown; + main?: string; + type?: string; + files?: string[]; + exports: { ".": { import?: string; default?: string }; "./client": { import?: string; default?: string } }; + mtmharness?: { secondary?: { id?: string; apiVersion?: number; client?: string } }; + }; + expect(manifest.dsh).toBeUndefined(); + expect(manifest.type).toBe("module"); + expect(manifest.main).toBe("./lib/client.js"); + expect(manifest.exports["."]).toMatchObject({ import: "./lib/client.js", default: "./lib/client.js" }); + expect(manifest.exports["./client"]).toMatchObject({ import: "./lib/client.js", default: "./lib/client.js" }); + expect(manifest.mtmharness?.secondary).toEqual({ id: "mtm-connect", apiVersion: 1, client: "./lib/client.js" }); + expect(manifest.files).toEqual(expect.arrayContaining(["lib/client.js", "lib/types/**/*.d.ts"])); + }); +}); diff --git a/packages/mtm-connect/tsconfig.json b/packages/mtm-connect/tsconfig.json new file mode 100644 index 0000000..c58de7e --- /dev/null +++ b/packages/mtm-connect/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "declaration": true, + "allowImportingTsExtensions": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "lib/types", + "rootDir": "src", + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.ts", "tests/**/*.tsx"] +} \ No newline at end of file diff --git a/packages/mtm-connect/vitest.config.ts b/packages/mtm-connect/vitest.config.ts new file mode 100644 index 0000000..a7856ab --- /dev/null +++ b/packages/mtm-connect/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts", "tests/**/*.test.tsx"], + }, +}); diff --git a/packages/mtmharness/README.md b/packages/mtmharness/README.md index 7ff6c3b..c017439 100644 --- a/packages/mtmharness/README.md +++ b/packages/mtmharness/README.md @@ -2,10 +2,10 @@ `mtmharness` is one public npm package with one unified DSH plugin and two explicit client identities: -- **DSH Web plugin**: the package root and `./client` export use the official `dsh.client` lazy-CJS contract. One installation provides the MTM sidebar action, the Connect control panel, and the settings-controlled Codebase Memory/Modern Go/Ponytail coding features. +- **DSH Web plugin**: the package root and `./client` export use the official `dsh.client` lazy-CJS contract. One installation provides the settings-controlled MTM Connect secondary extension and the Codebase Memory/Modern Go/Ponytail coding features. - **Independent web client**: the package also publishes a BrowserHistory static app and a MemoryHistory script/embed entry. These artifacts own their React root, router, styles, and teardown and never load the local coding runtime. -The DSH plugin is assembled from Connect and coding feature domains under one Host/Client lifecycle. Codebase Memory keeps its `codebase_memory` server namespace and `mcp__codebase_memory__*` tool names; Ponytail ships six skills inline, including `/ponytail` and its companion commands. The `mtm-coding` settings namespace remains the configuration contract inside the unified `mtmharness` package. +The DSH plugin is assembled from coding and secondary frontend domains under one Host/Client lifecycle. Codebase Memory keeps its `codebase_memory` server namespace and `mcp__codebase_memory__*` tool names; Ponytail ships six skills inline, including `/ponytail` and its companion commands. The `mtm-coding` settings namespace contains the runtime extension toggles. Modern Go Guidelines is enabled by default as the inline `use-modern-go` skill. The skill uses the bundled JetBrains `v0.1.1` wrapper to resolve guidance for the target project version. The wrapper installs its CLI only when a Go task runs it, caches the binary outside the project, and reports a missing Go toolchain instead of assuming the host is prepared. `modernGoCommand` can replace the bundled wrapper command; `modernGoEnabled` removes the skill from the DSH catalog. The redistributed wrapper and license live under `resources/go-modern-guidelines/` and remain Apache-2.0. @@ -18,22 +18,22 @@ Install the package into a web profile: dsh plugin --profile web add mtmharness dsh --profile web --dump-config -Restart the DSH Web host after changing profile composition. The `MTM` action appears in the sidebar footer. The host owns the React root, session connection, and lifecycle. +Restart the DSH Web host after changing profile composition. Open Settings > Plugins > Plugin configuration to control MTM Connect. The host owns the React root, session connection, and lifecycle. -The plugin enables the Connect control panel by default and keeps its registry and `/mtm-connect` RPC on the DSH Host loopback boundary. The independent static/embed client remains a separate application surface and is not part of the DSH plugin. +The plugin registers the `mtm-connect` settings namespace only. No local device backend, filesystem access, token, or loopback RPC is activated by this frontend experiment. The independent static/embed client remains a separate application surface and is not part of the DSH plugin. ## Secondary Extensions -`mtmharness` owns a runtime frontend-extension loader. The `Dynamic Canvas` setting is off by default; enabling it fetches the exact pinned `mtmcanvas` native ESM artifact, verifies its SHA-256 integrity, and mounts it through the `mount(context) -> cleanup` ABI. The extension contract passes only an owned DOM root, document, version, abort signal, and cleanup-registration callback; it does not expose DSH or Node.js internals. The ESM still runs with normal page privileges, so integrity is an identity check, not a browser security boundary; once native import starts, browser evaluation cannot be cancelled. Disabling the setting awaits cleanup and removes the owned root. +`mtmharness` owns a runtime frontend-extension loader. The `MTM Connect` setting is enabled by default; disabling it fetches no artifact, and re-enabling it loads the exact pinned `mtm-connect` native ESM artifact, verifies its SHA-256 integrity, and mounts it through the `mount(context) -> cleanup` ABI. The settings card also provides an `Open Connect` action after the panel is hidden. The extension contract passes only an owned DOM root, document, version, abort signal, and cleanup-registration callback; it does not expose DSH or Node.js internals. The ESM still runs with normal page privileges, so integrity is an identity check, not a browser security boundary. Disabling the setting awaits cleanup and removes the owned root. -`mtmcanvas` is an extension artifact, not a standard DSH plugin. Do not add it with `dsh plugin`; install only `mtmharness`. The default URL uses unpkg, but the manifest accepts any exact HTTPS static-host URL with CORS enabled. The host CSP must allow `connect-src` to the artifact origin and `script-src blob:` for the fetched ESM. The first browser-only experiment keeps Canvas data in memory; persistence and host capabilities are deferred. +`mtm-connect` is an extension artifact, not a standard DSH plugin. Do not add it with `dsh plugin`; install only `mtmharness`. The artifact is currently a browser-only mock of device and execution-world connections. The default URL uses unpkg, but the manifest accepts any exact HTTPS static-host URL with CORS enabled. The host CSP must allow `connect-src` to the artifact origin and `script-src blob:` for the fetched ESM. + +`mtmcanvas` remains a separate browser-only secondary artifact controlled by `Dynamic Canvas`, which stays off by default. Publish the pinned `mtm-connect` and `mtmcanvas` artifacts before `mtmharness`; the mtmharness release gate checks local, CDN, and manifest SHA-256 values. Profiles created from an older `mtmharness` release should remove retired `mtmcanvas`, `mtm-connect`, and `mtm-coding` rows before adding the current package. The committed profile smoke still covers the old-row cleanup and duplicate install path: pnpm --filter mtmharness run profile:check -- /path/to/mtmharness.tgz -`mtmcanvas` remains a separately published artifact so its browser code can be retrieved at runtime; its package has no `dsh.bundle` or `dsh.client` declaration. Publish the pinned `mtmcanvas` version before `mtmharness`; the mtmharness release gate checks the local, CDN, and manifest SHA-256 values. - ## Static App The package tarball contains `dist/standalone/index.html` and its hashed assets. Serve that directory as the static app root; the HTML uses relative asset URLs so it also works below a CDN or npm subpath. Configure the API origin and the pre-registered public OAuth client before the app script runs: diff --git a/packages/mtmharness/scripts/verify-package.mjs b/packages/mtmharness/scripts/verify-package.mjs index a1c7c3c..232c55b 100644 --- a/packages/mtmharness/scripts/verify-package.mjs +++ b/packages/mtmharness/scripts/verify-package.mjs @@ -70,7 +70,7 @@ for (const required of ["mtm-coding", "codebase_memory", "mtm-coding-modern-go", const client = read("lib/client.js"); if (!client.includes("window.__ModuleLoader__.load") || !client.includes('id: "mtmharness"')) fail("client artifact is not a DSH lazy-CJS bundle"); for (const required of [ - "/mtm-connect", + "mtm-connect", "mtm-coding", "mtm.coding", "ponytail", @@ -80,11 +80,13 @@ for (const required of [ "RTK", "shell.overlay", "mtmdsh-launcher-overlay", + "mtm.connect", + "MTM Connect", "https://unpkg.com/mtmharness@latest/dist/standalone/index.html", ]) { if (!client.includes(required)) fail("client artifact is missing unified feature surface: " + required); } -for (const forbidden of ["createRoot", "RouterProvider", "new WebSocket", 'credentials: "include"', "MtmHarnessRuntime", "standalone/src", 'id: "mtm-connect"', "/mtmdsh/"]) { +for (const forbidden of ["createRoot", "RouterProvider", "new WebSocket", 'credentials: "include"', "MtmHarnessRuntime", "standalone/src", "/mtmdsh/"]) { if (client.includes(forbidden)) fail("client artifact contains standalone behavior: " + forbidden); } diff --git a/packages/mtmharness/src/client/MtmHarnessAction.test.tsx b/packages/mtmharness/src/client/MtmHarnessAction.test.tsx deleted file mode 100644 index 3675c2b..0000000 --- a/packages/mtmharness/src/client/MtmHarnessAction.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// @vitest-environment jsdom -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, describe, expect, it } from "vitest"; -import { MtmConnectClientRuntime } from "../features/connect/client/runtime.ts"; -import { MtmHarnessAction, type MtmHarnessActionProps } from "./MtmHarnessAction.tsx"; - -(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -let root: ReturnType | undefined; -let container: HTMLDivElement | undefined; -let runtime: MtmConnectClientRuntime | undefined; - -afterEach(() => { - act(() => { root?.unmount(); }); - runtime?.dispose(); - container?.remove(); - root = undefined; - runtime = undefined; - container = undefined; - document.body.replaceChildren(); -}); - -function renderAction(wide: boolean): void { - const useConnect: MtmHarnessActionProps["useConnect"] = (selector) => selector(runtime!.getSnapshot()); - act(() => { - root?.render(); - }); -} - -describe("MtmHarnessAction", () => { - it("opens the unified MTM control panel", () => { - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - runtime = new MtmConnectClientRuntime({ fixture: true }); - renderAction(true); - - const trigger = document.querySelector('button[aria-label="打开 MTM"]'); - expect(trigger?.textContent).toContain("MTM"); - act(() => { trigger?.click(); }); - expect(document.body.textContent).toContain("连接"); - expect(document.body.textContent).toContain("Local workstation (fixture)"); - - const close = document.querySelector('button[aria-label="关闭 MTM"]'); - expect(close).not.toBeNull(); - act(() => { close?.click(); }); - expect(document.body.textContent).not.toContain("Local workstation (fixture)"); - }); - - it("uses a compact MTM mark in the rail", () => { - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - runtime = new MtmConnectClientRuntime({ fixture: true }); - renderAction(false); - - const trigger = document.querySelector('button[aria-label="打开 MTM"]'); - expect(trigger).not.toBeNull(); - expect(trigger?.textContent).toBe("MTM"); - }); -}); diff --git a/packages/mtmharness/src/client/MtmHarnessAction.tsx b/packages/mtmharness/src/client/MtmHarnessAction.tsx deleted file mode 100644 index ce65bfe..0000000 --- a/packages/mtmharness/src/client/MtmHarnessAction.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useState, type ReactElement } from "react"; -import type { PropsRuntime, InjectFace } from "@deepseek-ai/dsh-client-ui-slots"; -import { Button, Modal } from "@deepseek-ai/dsh-client-ui-primitives"; -import type {} from "@deepseek-ai/dsh-client-ui-sidebar/client"; -import { MtmConnectPanel, type MtmConnectPanelActions } from "../features/connect/client/MtmConnectPanel.tsx"; -import type { MtmConnectClientRuntime, MtmConnectViewState } from "../features/connect/client/runtime.ts"; - -export interface MtmHarnessActionInjected { - readonly actions: MtmConnectPanelActions; - readonly hooks: { - readonly connect: MtmConnectClientRuntime; - }; -} - -export type MtmHarnessActionProps = PropsRuntime<"sidebar.footer.action"> & InjectFace; - -export function MtmHarnessAction({ wide, actions, useConnect }: MtmHarnessActionProps): ReactElement { - const [open, setOpen] = useState(false); - const state = useConnect((snapshot): MtmConnectViewState => snapshot); - const label = "打开 MTM"; - - return ( - <> - - { setOpen(false); }} - title="MTM" - closeLabel="关闭 MTM" - className="mtm-modal" - contentClassName="mtm-modal-content" - > - - - - ); -} diff --git a/packages/mtmharness/src/client/index.test.ts b/packages/mtmharness/src/client/index.test.ts index 022d68f..7abce7d 100644 --- a/packages/mtmharness/src/client/index.test.ts +++ b/packages/mtmharness/src/client/index.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "vitest"; -import { createDemoRegistry } from "../features/connect/core/registry.ts"; import { apply as applyHost } from "../index.ts"; import { apply, inject } from "./index.ts"; @@ -12,7 +11,6 @@ type Registered = { function clientBench(loopback = true): { registered: Registered[]; cleanups: Array<() => void | Promise> } { const registered: Registered[] = []; const cleanups: Array<() => void | Promise> = []; - const snapshot = createDemoRegistry().getSnapshot(); const codingSettings = { status: "ready", value: { @@ -34,9 +32,18 @@ function clientBench(loopback = true): { registered: Registered[]; cleanups: Arr writable: true, mode: "host", }; + const connectSettings = { + status: "ready", + value: { enabled: false }, + base: {}, + user: {}, + revision: 1, + writable: true, + mode: "host", + }; const ctx = { get(name: string) { - if (name === "connection") return { isLoopback: loopback, rpc: { call: async () => ({ ok: true, value: snapshot }) } }; + if (name === "connection") return { isLoopback: loopback, rpc: { call: async () => ({ ok: true, value: {} }) } }; throw new Error("unexpected service: " + name); }, provide() {}, @@ -45,12 +52,17 @@ function clientBench(loopback = true): { registered: Registered[]; cleanups: Arr register: () => () => {}, }, settingsScope: { - bind: () => ({ + bind: (spec: { namespace: string }) => spec.namespace === "mtm-connect" ? { + getSnapshot: () => connectSettings, + subscribe: () => () => {}, + set: async () => {}, + unset: async () => {}, + } : { getSnapshot: () => codingSettings, subscribe: () => () => {}, set: async () => {}, unset: async () => {}, - }), + }, }, effect(effect: () => (() => void | Promise) | void) { const cleanup = effect(); @@ -82,8 +94,8 @@ function clientBench(loopback = true): { registered: Registered[]; cleanups: Arr return { registered, cleanups }; } -async function hostBench(): Promise<{ provided: Record; cleanups: Array<() => void | Promise> }> { - const provided: Record = {}; +async function hostBench(): Promise<{ registeredNamespaces: string[]; cleanups: Array<() => void | Promise> }> { + const registeredNamespaces: string[] = []; const cleanups: Array<() => void | Promise> = []; const settings = { codebaseMemoryEnabled: false, @@ -114,11 +126,11 @@ async function hostBench(): Promise<{ provided: Record; cleanup const ctx = { connection: { rpc: { handle() { return async () => {}; } } }, settings: { - register() { + register(namespace: unknown) { + registeredNamespaces.push(String(namespace)); return { get: () => settings, watch: () => () => {} }; }, }, - provide(key: string, value: unknown) { provided[key] = value; }, effect(effect: () => (() => void | Promise) | void) { const cleanup = effect(); if (typeof cleanup === "function") cleanups.push(cleanup); @@ -126,24 +138,21 @@ async function hostBench(): Promise<{ provided: Record; cleanup }, }; await applyHost(ctx as never); - return { provided, cleanups }; + return { registeredNamespaces, cleanups }; } describe("mtmharness Host half", () => { - it("assembles the Host-owned Connect control plane", async () => { - const { provided, cleanups } = await hostBench(); - expect(provided.mtmConnect).toBeDefined(); + it("registers the Connect settings namespace without a local backend", async () => { + const { registeredNamespaces, cleanups } = await hostBench(); + expect(registeredNamespaces).toContain("mtm-connect"); for (const cleanup of cleanups.reverse()) await cleanup(); }); - it("fails clearly when the Host connection service is unavailable", async () => { - await expect(applyHost({} as never)).rejects.toThrow("mtmharness: DSH connection service is unavailable"); - }); }); describe("mtmharness browser half", () => { it("declares the combined service dependencies", () => { - expect(inject).toEqual(["slots", "connection", "locale", "settingsScope"]); + expect(inject).toEqual(["slots", "locale", "settingsScope"]); }); it("only exposes update actions for loopback connections", () => { @@ -160,39 +169,16 @@ describe("mtmharness browser half", () => { for (const cleanup of remote.cleanups.reverse()) void cleanup(); }); - it("fails clearly when the Client connection service is unavailable", () => { - const cleanups: Array<() => void | Promise> = []; - const ctx = { - get(name: string) { - if (name === "connection") return undefined; - throw new Error("unexpected service: " + name); - }, - provide() {}, - effect(effect: () => (() => void | Promise) | void) { - const cleanup = effect(); - if (typeof cleanup === "function") cleanups.push(cleanup); - return cleanup; - }, - sessions: { provide() { return () => {}; } }, - slots: { - inject(_name: string, callback: () => () => void) { - const cleanup = callback(); - cleanups.push(cleanup); - return cleanup; - }, - register() { return () => {}; }, - }, - }; - expect(() => apply(ctx as never)).toThrow("mtmharness: DSH connection service is unavailable"); - for (const cleanup of cleanups.reverse()) void cleanup(); - }); - - it("registers the MTM Harness surface with Connect under one lifecycle", () => { + it("keeps Connect in settings and leaves the sidebar footer for the cloud launcher", () => { const { registered, cleanups } = clientBench(); expect(registered).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: "sidebar.footer.action", options: expect.objectContaining({ id: "mtmharness", order: 10 }) }), + expect.objectContaining({ name: "settings.plugin.item", options: expect.objectContaining({ key: "mtm-coding" }) }), + expect.objectContaining({ name: "settings.plugin.item", options: expect.objectContaining({ key: "mtm-connect" }) }), + ])); + expect(registered.filter((entry) => entry.name === "sidebar.footer.action")).toHaveLength(1); + expect(registered).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "sidebar.footer.action", options: expect.objectContaining({ id: "mtmharness" }) }), ])); - expect(registered.filter((entry) => entry.name === "sidebar.footer.action")).toHaveLength(2); expect(registered).toEqual(expect.arrayContaining([ expect.objectContaining({ name: "sidebar.footer.action", options: expect.objectContaining({ id: "mtmdsh-launcher", order: 12 }) }), expect.objectContaining({ name: "shell.overlay", options: expect.objectContaining({ id: "mtmdsh-launcher-overlay", order: 100 }) }), diff --git a/packages/mtmharness/src/client/index.ts b/packages/mtmharness/src/client/index.ts index 9cad880..8681b2a 100644 --- a/packages/mtmharness/src/client/index.ts +++ b/packages/mtmharness/src/client/index.ts @@ -6,41 +6,19 @@ import type {} from "@deepseek-ai/dsh-client-ui-settings-plugins/client"; import type {} from "@deepseek-ai/dsh-client-ui-sidebar/client"; import type {} from "@deepseek-ai/dsh-client-ui-layout/client"; import { apply as applyCoding } from "../features/coding/client/index.tsx"; +import { apply as applyMtmConnect } from "../features/mtm-connect/client/index.tsx"; import { apply as applySecondary } from "../features/secondary/client.ts"; -import { apply as applyConnect } from "../features/connect/client/index.ts"; -import type { MtmConnectPanelActions } from "../features/connect/client/MtmConnectPanel.tsx"; -import { MtmHarnessAction, type MtmHarnessActionInjected } from "./MtmHarnessAction.tsx"; import { MtmHarnessLauncherAction, MtmHarnessLauncherOverlay } from "./launcher.tsx"; import { disposeMtmHarnessLauncher } from "./launcher-state.ts"; export { applyCoding }; -export const inject = ["slots", "connection", "locale", "settingsScope"]; +export const inject = ["slots", "locale", "settingsScope"]; -/** Register every MTM and coding feature under one plugin-owned lifecycle. */ +/** Register coding, secondary, and launcher features under one plugin-owned lifecycle. */ export function apply(ctx: ClientContext): void { - if (ctx.get("connection") === undefined) throw new Error("mtmharness: DSH connection service is unavailable"); applyCoding(ctx); + applyMtmConnect(ctx); applySecondary(ctx); - const runtime = applyConnect(ctx); - const actions: MtmConnectPanelActions = { - selectConnection: (connectionId) => { runtime.selectConnection(connectionId); }, - refresh: () => { runtime.refresh(); }, - createMockConnection: () => { runtime.createMockConnection(); }, - enableSelected: () => { runtime.enableSelected(); }, - disableSelected: () => { runtime.disableSelected(); }, - revokeSelected: () => { runtime.revokeSelected(); }, - reconnectSelected: () => { runtime.reconnectSelected(); }, - setCapabilityEnabled: (capabilityId, enabled) => { runtime.setCapabilityEnabled(capabilityId, enabled); }, - setModelInvocable: (capabilityId, enabled) => { runtime.setModelInvocable(capabilityId, enabled); }, - setUserInvocable: (capabilityId, enabled) => { runtime.setUserInvocable(capabilityId, enabled); }, - setEventPolicy: (capabilityId, policy) => { runtime.setEventPolicy(capabilityId, policy); }, - }; - ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({ - name: "sidebar.footer.action", - id: "mtmharness", - order: 10, - inject: (): MtmHarnessActionInjected => ({ actions, hooks: { connect: runtime } }), - }, MtmHarnessAction)); // The launcher loads the latest stable app directly from the package CDN. ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({ name: "sidebar.footer.action", diff --git a/packages/mtmharness/src/features/connect/adapters/catalog.ts b/packages/mtmharness/src/features/connect/adapters/catalog.ts deleted file mode 100644 index 58d771f..0000000 --- a/packages/mtmharness/src/features/connect/adapters/catalog.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { validateAdapterDescriptor, type AdapterDescriptor } from "../contract/adapter.ts"; - -const MOCK_WORLD: AdapterDescriptor = { - id: "mock-world", - version: "0.1.0", - label: "Mock workstation", - summary: "A deterministic primary execution-world fixture.", - status: "installed", - kind: "mock-world", - setupMethods: [{ id: "mock.setup", label: "Use fixture workspace", kind: "mock", status: "available" }], - capabilities: [{ - id: "workspace.execution", - version: "0.1.0", - label: "Workspace and process view", - role: "primary-world", - eventKinds: ["workspace.changed", "process.exited"], - operations: [ - { id: "workspace.list", label: "List fixture workspace", kind: "one-shot", sideEffect: "read", requiresApproval: false }, - { id: "process.list", label: "Inspect fixture processes", kind: "one-shot", sideEffect: "read", requiresApproval: false }, - ], - limits: { maxInputBytes: 2_048, maxOutputBytes: 8_192 }, - supportedTargets: ["fixture-workstation"], - }], -}; - -const MOCK_DEVICE: AdapterDescriptor = { - id: "mock-device", - version: "0.1.0", - label: "Mock Android device", - summary: "A deterministic additive device-capability fixture.", - status: "installed", - kind: "mock-device", - setupMethods: [{ id: "mock.device.setup", label: "Pair fixture device", kind: "mock", status: "available" }], - capabilities: [{ - id: "device.control", - version: "0.1.0", - label: "Screen and input control", - role: "additive-capability", - eventKinds: ["device.notification", "device.screen.changed"], - operations: [ - { id: "screen.snapshot", label: "Capture fixture screen", kind: "one-shot", sideEffect: "read", requiresApproval: false }, - { id: "input.tap", label: "Tap fixture screen", kind: "one-shot", sideEffect: "write", requiresApproval: true }, - ], - limits: { maxInputBytes: 2_048, maxOutputBytes: 8_192 }, - supportedTargets: ["fixture-android"], - }], -}; - -function unavailable( - id: string, - label: string, - summary: string, - setup: { id: string; label: string; kind: "manual" | "device-code" | "oauth" }, -): AdapterDescriptor { - return { - id, - version: "0.1.0", - label, - summary, - status: "unavailable", - kind: "unavailable", - setupMethods: [{ ...setup, status: "unavailable" }], - capabilities: [], - availabilityNote: "Adapter is listed for discovery only; the P0 release does not provide it.", - }; -} - -const UNAVAILABLE: readonly AdapterDescriptor[] = [ - unavailable("ssh", "SSH host", "Remote Linux execution world.", { id: "ssh.credentials", label: "Enter host credentials", kind: "manual" }), - unavailable("android", "Android device", "Device bridge and APK enrollment.", { id: "android.pair", label: "Pair with device code", kind: "device-code" }), - unavailable("chrome", "Chrome extension", "Browser tab and profile capability.", { id: "chrome.oauth", label: "Authorize browser extension", kind: "oauth" }), - unavailable("cloudflare-container", "Cloudflare container", "Managed remote execution world.", { id: "container.setup", label: "Configure container", kind: "manual" }), -]; - -export function createAdapterCatalog(): AdapterDescriptor[] { - return [MOCK_WORLD, MOCK_DEVICE, ...UNAVAILABLE].map((descriptor) => validateAdapterDescriptor(JSON.parse(JSON.stringify(descriptor)))); -} - -export function installedAdapters(adapters: readonly AdapterDescriptor[]): AdapterDescriptor[] { - return adapters.filter((adapter) => adapter.status === "installed"); -} diff --git a/packages/mtmharness/src/features/connect/adapters/invoker.ts b/packages/mtmharness/src/features/connect/adapters/invoker.ts deleted file mode 100644 index a402b28..0000000 --- a/packages/mtmharness/src/features/connect/adapters/invoker.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { AdapterDescriptor, CapabilityDescriptor, OperationDescriptor } from "../contract/adapter.ts"; -import type { ConnectionInstance } from "../contract/connection.ts"; -import type { MtmModelProfileRef } from "../contract/control-plane.ts"; -import type { JsonObject } from "../contract/json.ts"; - -/** Validated metadata and public input passed to one local adapter execution. */ -export interface CapabilityInvocationContext { - readonly adapter: AdapterDescriptor; - readonly capability: CapabilityDescriptor; - readonly operation: OperationDescriptor; - readonly connection: ConnectionInstance; - /** Authoritative selected profile reference, without profile contents or secrets. */ - readonly modelProfile?: MtmModelProfileRef; - readonly input: JsonObject; -} - -export type CapabilityInvocationExecutionResult = - | { - readonly ok: true; - readonly simulated: boolean; - readonly summary: string; - readonly data: JsonObject; - } - | { - readonly ok: false; - readonly code: "adapter-unavailable" | "unsupported-operation" | "invalid-input"; - readonly message: string; - }; - -export type CapabilityInvoker = (context: CapabilityInvocationContext) => CapabilityInvocationExecutionResult | PromiseLike; diff --git a/packages/mtmharness/src/features/connect/adapters/mock/invoke.ts b/packages/mtmharness/src/features/connect/adapters/mock/invoke.ts deleted file mode 100644 index f5adf80..0000000 --- a/packages/mtmharness/src/features/connect/adapters/mock/invoke.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { JsonObject } from "../../contract/json.ts"; -import type { CapabilityInvocationExecutionResult, CapabilityInvoker } from "../invoker.ts"; - -export type MockInvocationResult = CapabilityInvocationExecutionResult; - -function numberInput(input: JsonObject, key: string): number | undefined { - const value = input[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -export function invokeMockCapability( - adapterId: string, - capabilityId: string, - operationId: string, - input: JsonObject, -): MockInvocationResult { - if (adapterId === "mock-world" && capabilityId === "workspace.execution" && operationId === "workspace.list") { - const path = typeof input.path === "string" && input.path.length > 0 ? input.path : "/workspace/demo"; - return { - ok: true, - simulated: true, - summary: "Fixture workspace is reachable", - data: { - path, - entries: [ - { name: "README.md", kind: "file", size: 1842 }, - { name: "src", kind: "directory", size: 0 }, - { name: "package.json", kind: "file", size: 912 }, - ], - filesystem: "mock-world", - }, - }; - } - if (adapterId === "mock-world" && capabilityId === "workspace.execution" && operationId === "process.list") { - return { - ok: true, - simulated: true, - summary: "Fixture process table is available", - data: { - processes: [ - { pid: 214, command: "dsh web", status: "running" }, - { pid: 421, command: "node worker.mjs", status: "sleeping" }, - ], - processWorld: "mock-world", - }, - }; - } - if (adapterId === "mock-device" && capabilityId === "device.control" && operationId === "screen.snapshot") { - return { - ok: true, - simulated: true, - summary: "Fixture device screen captured", - data: { - target: "Pixel 8 fixture", - resolution: "1080x2400", - foregroundApp: "Settings", - screen: "mock://android/settings", - }, - }; - } - if (adapterId === "mock-device" && capabilityId === "device.control" && operationId === "input.tap") { - const x = numberInput(input, "x"); - const y = numberInput(input, "y"); - if (x === undefined || y === undefined || x < 0 || y < 0) return { ok: false, code: "invalid-input", message: "input.tap requires non-negative x and y coordinates" }; - return { - ok: true, - simulated: true, - summary: "Fixture device accepted the tap", - data: { target: "Pixel 8 fixture", x, y, accepted: true }, - }; - } - return { ok: false, code: "unsupported-operation", message: "The selected fixture does not implement this operation" }; -} - -export const mockCapabilityInvoker: CapabilityInvoker = ({ adapter, capability, operation, input }) => - invokeMockCapability(adapter.id, capability.id, operation.id, input); diff --git a/packages/mtmharness/src/features/connect/client/MtmConnectPanel.test.tsx b/packages/mtmharness/src/features/connect/client/MtmConnectPanel.test.tsx deleted file mode 100644 index dbb3359..0000000 --- a/packages/mtmharness/src/features/connect/client/MtmConnectPanel.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -// @vitest-environment jsdom -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, describe, expect, it } from "vitest"; -import { MtmConnectClientRuntime } from "./runtime.ts"; -import { MtmConnectPanel } from "./MtmConnectPanel.tsx"; - -(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -let root: ReturnType | undefined; -let container: HTMLDivElement | undefined; -let runtime: MtmConnectClientRuntime | undefined; - -afterEach(() => { - act(() => { root?.unmount(); }); - runtime?.dispose(); - container?.remove(); - root = undefined; - runtime = undefined; - container = undefined; - document.body.replaceChildren(); -}); - -function renderPanel(): void { - act(() => { root?.render(); }); -} - -describe("MtmConnectPanel", () => { - it("renders the compact fixture control surface", () => { - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - runtime = new MtmConnectClientRuntime({ fixture: true }); - renderPanel(); - - expect(document.body.textContent).toContain("连接"); - expect(document.body.textContent).toContain("Local workstation (fixture)"); - expect(document.body.textContent).not.toContain("Unavailable adapters"); - expect(document.body.textContent).not.toContain("Connection control plane"); - }); - - it("updates the snapshot when a fixture connection is enabled", () => { - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - runtime = new MtmConnectClientRuntime({ fixture: true }); - renderPanel(); - - act(() => { document.querySelector("button.mtmc-action-button-primary")?.click(); }); - renderPanel(); - expect(document.body.textContent).toContain("在线"); - expect(runtime.getSnapshot().snapshot.connections[0]?.observation.status).toBe("online"); - }); -}); diff --git a/packages/mtmharness/src/features/connect/client/MtmConnectPanel.tsx b/packages/mtmharness/src/features/connect/client/MtmConnectPanel.tsx deleted file mode 100644 index 0a673a3..0000000 --- a/packages/mtmharness/src/features/connect/client/MtmConnectPanel.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import type { ReactElement } from "react"; -import { Button, Pill } from "@deepseek-ai/dsh-client-ui-primitives"; -import { EVENT_POLICIES, type EventPolicy } from "../contract/event.ts"; -import type { CapabilityBinding, ConnectionRecord } from "../contract/connection.ts"; -import type { AdapterDescriptor } from "../contract/adapter.ts"; -import type { MtmConnectClientActions, MtmConnectViewState } from "./runtime.ts"; - -export type MtmConnectPanelActions = Pick; - -const STATUS_LABELS: Record = { - configured: "已配置", - authorizing: "授权中", - connecting: "连接中", - enrolled: "已注册", - online: "在线", - degraded: "降级", - offline: "离线", - revoked: "已撤销", - unavailable: "不可用", -}; - -function statusLabel(status: string): string { - return STATUS_LABELS[status] ?? status; -} - -function StatusBadge({ status }: { status: string }): ReactElement { - return {statusLabel(status)}; -} - -function selectedRecord(state: MtmConnectViewState): ConnectionRecord | undefined { - const id = state.selectedConnectionId; - return state.snapshot.connections.find((record) => record.instance.id === id) ?? state.snapshot.connections[0]; -} - -function selectedAdapter(state: MtmConnectViewState, record: ConnectionRecord | undefined): AdapterDescriptor | undefined { - return record === undefined ? undefined : state.snapshot.adapters.find((adapter) => adapter.id === record.instance.adapterId); -} - -function CapabilityCard({ - capabilityId, - adapter, - record, - actions, -}: { - capabilityId: string; - adapter: AdapterDescriptor; - record: ConnectionRecord; - actions: MtmConnectPanelActions; -}): ReactElement | null { - const capability = adapter.capabilities.find((candidate) => candidate.id === capabilityId); - const binding: CapabilityBinding | undefined = record.instance.bindings[capabilityId]; - if (capability === undefined || binding === undefined) return null; - return ( -
-
- {capability.label} - {capability.role === "primary-world" ? "主执行环境" : "附加能力"}
{capability.operations.map((operation) => operation.label + (operation.requiresApproval ? " · 需确认" : "")).join(", ")}
-
-
- 能力策略 -
- - - - -
-
-
- ); -} - -function ConnectionDetail({ state, actions }: { state: MtmConnectViewState; actions: MtmConnectPanelActions }): ReactElement { - const record = selectedRecord(state); - const adapter = selectedAdapter(state, record); - if (record === undefined || adapter === undefined) return
暂无可用连接
; - const online = record.observation.status === "online"; - const revoked = record.observation.status === "revoked"; - const root = record.instance.config.root; - return ( -
-
-
- {record.instance.label} -
{adapter.label} · {record.instance.fixture ? "测试连接" : "托管连接"}
-
- -
-
-
目标状态{record.instance.desired === "enabled" ? "启用" : "停用"}
-
连接代次{record.observation.generation}
-
目标{String(root ?? adapter.capabilities[0]?.supportedTargets[0] ?? "测试环境")}
-
-
- - - - -
-
能力
{adapter.capabilities.length} 项
- {adapter.capabilities.map((capability) => )} -
- ); -} - -export function MtmConnectPanel({ state, actions }: { state: MtmConnectViewState; actions: MtmConnectPanelActions }): ReactElement { - const online = state.snapshot.connections.filter((record) => record.observation.status === "online").length; - const enabled = state.snapshot.connections.filter((record) => record.instance.desired === "enabled").length; - return ( -
-
-

连接

管理连接状态和可用能力。

-
-
- {state.snapshot.connections.length} 个连接 - {enabled} 个已启用 - {online} 个在线 -
- {state.loading ?
正在读取连接状态
: null} -
-
-

连接

-
- {state.snapshot.connections.length === 0 ?
暂无连接
: state.snapshot.connections.map((record) => ( - - ))} -
-
-
-
- {state.notice !== undefined ?
{state.notice}
: null} -
- ); -} diff --git a/packages/mtmharness/src/features/connect/client/index.test.ts b/packages/mtmharness/src/features/connect/client/index.test.ts deleted file mode 100644 index b1668e8..0000000 --- a/packages/mtmharness/src/features/connect/client/index.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -// @vitest-environment jsdom -import { describe, expect, it } from "vitest"; -import { createDemoRegistry } from "../core/registry.ts"; -import { apply } from "./index.ts"; - -interface Registered { - readonly options: Record; - readonly component: unknown; -} - -describe("mtm-connect browser half", () => { - it("declares the connection dependency, hydrates Host state, and cleans up", async () => { - const registered: Registered[] = []; - const cleanups: Array<() => void | Promise> = []; - const snapshot = createDemoRegistry().getSnapshot(); - const calls: unknown[] = []; - const ctx = { - get(name: string) { - if (name !== "connection") throw new Error("unexpected service: " + name); - return { - rpc: { - call: async (channel: string, endpoint: string, payload: unknown) => { - calls.push({ channel, endpoint, payload }); - return { ok: true, value: snapshot }; - }, - }, - }; - }, - effect(effect: () => (() => void | Promise) | void) { - const cleanup = effect(); - if (typeof cleanup === "function") cleanups.push(cleanup); - return cleanup; - }, - slots: { - inject() { - return () => {}; - }, - register(options: Record, component: unknown) { - const entry = { options, component }; - registered.push(entry); - return () => { - const index = registered.indexOf(entry); - if (index >= 0) registered.splice(index, 1); - }; - }, - }, - }; - apply(ctx as never); - expect(registered).toHaveLength(0); - const style = document.head.querySelector('style[data-plugin="mtm-connect"]'); - expect(style?.dataset.pluginCss).toBe("mtm-connect/inline.css"); - expect(style?.textContent).toContain("--dsw-alias-label-primary"); - await new Promise((resolve) => { queueMicrotask(resolve); }); - expect(calls).toHaveLength(1); - expect(calls[0]).toMatchObject({ channel: "/mtm-connect", endpoint: "request" }); - for (const cleanup of cleanups.reverse()) await cleanup(); - expect(registered).toHaveLength(0); - expect(document.head.querySelector('style[data-plugin="mtm-connect"]')).toBeNull(); - }); -}); diff --git a/packages/mtmharness/src/features/connect/client/index.ts b/packages/mtmharness/src/features/connect/client/index.ts deleted file mode 100644 index 8004b05..0000000 --- a/packages/mtmharness/src/features/connect/client/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ClientContext } from "@deepseek-ai/dsh-client-runtime/client"; -import type { ConnectionHandle } from "@deepseek-ai/dsh-client-connection/client"; -import { createMtmConnectTransport, MtmConnectClientRuntime } from "./runtime.ts"; -import { MTM_CONNECT_CSS } from "./styles.ts"; - -/** Mount the browser connection runtime and its scoped styles. */ -export function apply(ctx: ClientContext): MtmConnectClientRuntime { - const connection = ctx.get("connection") as ConnectionHandle | undefined; - if (connection === undefined) throw new Error("mtm-connect: DSH connection service is unavailable"); - const runtime = new MtmConnectClientRuntime({ transport: createMtmConnectTransport(connection.rpc) }); - ctx.effect(() => () => { runtime.dispose(); }, "mtm-connect: client runtime"); - ctx.effect(() => { - if (typeof document === "undefined") return () => {}; - const style = document.createElement("style"); - style.dataset.plugin = "mtm-connect"; - style.dataset.pluginCss = "mtm-connect/inline.css"; - style.textContent = MTM_CONNECT_CSS; - document.head.append(style); - return () => { style.remove(); }; - }, "mtm-connect: styles"); - return runtime; -} diff --git a/packages/mtmharness/src/features/connect/client/runtime.test.ts b/packages/mtmharness/src/features/connect/client/runtime.test.ts deleted file mode 100644 index 50de2d2..0000000 --- a/packages/mtmharness/src/features/connect/client/runtime.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createMtmConnectRpcHandler } from "../index.ts"; -import { createDemoRegistry } from "../core/registry.ts"; -import { createMtmConnectTransport, MtmConnectClientRuntime } from "./runtime.ts"; - -describe("mtm-connect Host/Client transport", () => { - it("requires an explicit fixture mode when no Host transport is supplied", () => { - expect(() => new MtmConnectClientRuntime()).toThrow("mtm-connect: Host transport is required"); - }); - - it("rejects a v1 initial remote snapshot after the schema hard cut", () => { - expect(() => new MtmConnectClientRuntime({ - transport: {} as never, - snapshot: { schemaVersion: 1, revision: 0, ownerId: "demo-user", adapters: [], connections: [], eventHistory: [], updatedAt: 0 } as never, - })).toThrow("unsupported mtm-connect snapshot version"); - }); - - it("rejects a v2 snapshot without the profile reference field", () => { - expect(() => new MtmConnectClientRuntime({ - transport: {} as never, - snapshot: { schemaVersion: 2, revision: 0, ownerId: "demo-user", adapters: [], connections: [], eventHistory: [], updatedAt: 0 } as never, - })).toThrow("mtm model profile"); - }); - - it("routes a sandbox control snapshot through the Host transport", async () => { - const host = createDemoRegistry(() => Date.now(), { - sandboxId: "sbx_00000000-0000-4000-8000-000000000607", - workspaceId: "ws_00000000-0000-4000-8000-000000000607", - owner: { issuer: "https://auth.example.test", subject: "demo-user" }, - }); - const handler = createMtmConnectRpcHandler(host, { allowControlReconcile: true }); - const rpc = { call: (channel: string, endpoint: string, payload: unknown, signal?: AbortSignal) => handler(endpoint, payload, signal ?? new AbortController().signal) }; - const transport = createMtmConnectTransport(rpc); - const response = await transport.reconcile({ - contractVersion: 2, - scope: { - sandboxId: "sbx_00000000-0000-4000-8000-000000000607", - workspaceId: "ws_00000000-0000-4000-8000-000000000607", - owner: { issuer: "https://auth.example.test", subject: "demo-user" }, - }, - revision: 1, - adapters: [], - desiredWorlds: [], - observedWorlds: [], - installation: null, - activeModelProfile: null, - }); - expect(response.snapshot.ownerId).toBe("demo-user"); - expect(host.getControlRevision()).toBe(1); - host.dispose(); - }); - - it("hydrates the Client from Host state and sends mutations back to the same registry", async () => { - const host = createDemoRegistry(); - const handler = createMtmConnectRpcHandler(host); - const rpc = { - call: (channel: string, endpoint: string, payload: unknown, signal?: AbortSignal) => { - expect(channel).toBe("/mtm-connect"); - expect(endpoint).toBe("request"); - return handler(endpoint, payload, signal ?? new AbortController().signal); - }, - }; - const client = new MtmConnectClientRuntime({ transport: createMtmConnectTransport(rpc) }); - try { - await vi.waitFor(() => { expect(client.getSnapshot().snapshot.ownerId).toBe("demo-user"); }); - expect(client.getSnapshot().snapshot.revision).toBe(host.getSnapshot().revision); - client.enableSelected(); - await vi.waitFor(() => { expect(host.getConnection("mock-workstation")?.observation.status).toBe("online"); }); - await vi.waitFor(() => { expect(client.getSnapshot().snapshot.revision).toBe(host.getSnapshot().revision); }); - expect(client.getSnapshot().snapshot.connections[0]?.observation.status).toBe("online"); - } finally { - client.dispose(); - host.dispose(); - } - }); -}); diff --git a/packages/mtmharness/src/features/connect/client/runtime.ts b/packages/mtmharness/src/features/connect/client/runtime.ts deleted file mode 100644 index 8150a08..0000000 --- a/packages/mtmharness/src/features/connect/client/runtime.ts +++ /dev/null @@ -1,370 +0,0 @@ -import type { ObservableSnapshot } from "@deepseek-ai/dsh-client-runtime/client"; -import type { ClientConnectionRpc } from "@deepseek-ai/dsh-client-connection/client"; -import type { EventProjection, EventPolicy, ExternalConnectionEvent } from "../contract/event.ts"; -import type { CapabilityInvocationResult, MtmConnectInvocationRequest, MtmConnectMutation, MtmConnectSnapshot } from "../contract/connection.ts"; -import type { MtmControlSnapshot } from "../contract/control-plane.ts"; -import { MTM_CONNECT_CHANNEL, assertMtmConnectInvocationResult, assertMtmConnectMutationResponse, assertMtmConnectSnapshot, type MtmConnectMutationResponse, type MtmConnectRpcRequest } from "../contract/rpc.ts"; -import type { JsonObject } from "../contract/json.ts"; -import { validateSnapshot } from "../contract/snapshot.ts"; -import { MtmConnectRegistry } from "../core/registry.ts"; - -export interface MtmConnectTransport { - snapshot(signal?: AbortSignal): Promise; - mutate(mutation: MtmConnectMutation, signal?: AbortSignal): Promise; - reconcile(snapshot: MtmControlSnapshot, signal?: AbortSignal): Promise; - invoke(request: MtmConnectInvocationRequest, signal?: AbortSignal): Promise; -} - -export function createMtmConnectTransport(rpc: ClientConnectionRpc): MtmConnectTransport { - async function call(request: MtmConnectRpcRequest, signal?: AbortSignal): Promise { - const result = await rpc.call(MTM_CONNECT_CHANNEL, "request", { args: request }, signal); - if (!result.ok) throw new Error(result.error.message); - return result.value; - } - return { - async snapshot(signal) { - const value = await call({ kind: "snapshot" }, signal); - assertMtmConnectSnapshot(value); - return validateSnapshot(value); - }, - async mutate(mutation, signal) { - const value = await call({ kind: "mutate", mutation }, signal); - assertMtmConnectMutationResponse(value); - return { snapshot: validateSnapshot(value.snapshot), ...(value.projection === undefined ? {} : { projection: value.projection }) }; - }, - async reconcile(snapshot, signal) { - const value = await call({ kind: "reconcile", snapshot }, signal); - assertMtmConnectMutationResponse(value); - return { snapshot: validateSnapshot(value.snapshot), ...(value.projection === undefined ? {} : { projection: value.projection }) }; - }, - async invoke(request, signal) { - const value = await call({ kind: "invoke", request }, signal); - assertMtmConnectInvocationResult(value); - return value; - }, - }; -} - -export interface MtmConnectViewState { - readonly snapshot: MtmConnectSnapshot; - readonly selectedConnectionId?: string; - readonly lastProjection?: EventProjection; - readonly lastInvocation?: CapabilityInvocationResult; - readonly notice?: string; - readonly loading: boolean; -} - -export interface MtmConnectClientActions { - selectConnection(connectionId: string): void; - refresh(): void; - createMockConnection(): void; - enableSelected(): void; - disableSelected(): void; - revokeSelected(): void; - reconnectSelected(): void; - setCapabilityEnabled(capabilityId: string, enabled: boolean): void; - setModelInvocable(capabilityId: string, enabled: boolean): void; - setUserInvocable(capabilityId: string, enabled: boolean): void; - setEventPolicy(capabilityId: string, policy: EventPolicy): void; - simulateEvent(): void; - invokeFirstCapability(): void; - approveFirstCapability(): void; -} - -export interface MtmConnectClientRuntimeOptions { - readonly snapshot?: MtmConnectSnapshot; - readonly now?: () => number; - readonly fixture?: boolean; - readonly transport?: MtmConnectTransport; -} - -export class MtmConnectClientRuntime implements ObservableSnapshot, MtmConnectClientActions { - private readonly registry: MtmConnectRegistry | undefined; - private readonly transport: MtmConnectTransport | undefined; - private readonly listeners = new Set<() => void>(); - private readonly unsubscribeRegistry: () => void; - private readonly abortController = new AbortController(); - private readonly now: () => number; - private mutationTail = Promise.resolve(); - private sequence = 1; - private disposed = false; - private view: MtmConnectViewState; - - constructor(options: MtmConnectClientRuntimeOptions = {}) { - this.now = options.now ?? (() => Date.now()); - this.transport = options.transport; - if (this.transport === undefined && options.fixture !== true) { - throw new Error("mtm-connect: Host transport is required"); - } - if (this.transport === undefined) { - const ownerId = options.snapshot?.ownerId ?? "demo-user"; - this.registry = new MtmConnectRegistry(options.snapshot === undefined - ? { ownerId, seed: options.fixture !== false, now: this.now } - : { ownerId, snapshot: options.snapshot, now: this.now }); - const snapshot = this.registry.getSnapshot(); - this.view = { snapshot, selectedConnectionId: snapshot.connections[0]?.instance.id, loading: false }; - this.unsubscribeRegistry = this.registry.subscribe(() => { this.adoptSnapshot(this.registry?.getSnapshot()); }); - } else { - this.registry = undefined; - const initialSnapshot = options.snapshot === undefined - ? { schemaVersion: 2 as const, revision: 0, ownerId: "pending", adapters: [], connections: [], activeModelProfile: null, eventHistory: [], updatedAt: 0 } - : validateSnapshot(options.snapshot); - this.view = { snapshot: initialSnapshot, loading: true }; - this.unsubscribeRegistry = () => {}; - void this.loadSnapshot(); - } - } - - getSnapshot = (): MtmConnectViewState => this.view; - - subscribe = (listener: () => void): (() => void) => { - this.listeners.add(listener); - return () => { this.listeners.delete(listener); }; - }; - - selectConnection(connectionId: string): void { - if (!this.view.snapshot.connections.some((record) => record.instance.id === connectionId)) return; - this.setView({ selectedConnectionId: connectionId, notice: undefined }); - } - - refresh(): void { - if (this.transport === undefined) return; - void this.loadSnapshot(); - } - - createMockConnection(): void { - this.mutate({ type: "create", adapterId: "mock-world", label: "New workstation (fixture)", config: { root: "/workspace/demo-new", transport: "in-memory" } }); - } - - enableSelected(): void { - this.withSelected((connectionId) => { this.mutate({ type: "enable", connectionId }); }); - } - - disableSelected(): void { - this.withSelected((connectionId) => { this.mutate({ type: "disable", connectionId }); }); - } - - revokeSelected(): void { - this.withSelected((connectionId) => { this.mutate({ type: "revoke", connectionId }); }); - } - - reconnectSelected(): void { - this.withSelected((connectionId) => { this.mutate({ type: "reconnect", connectionId }); }); - } - - setCapabilityEnabled(capabilityId: string, enabled: boolean): void { - this.setPolicy(capabilityId, { enabled }); - } - - setModelInvocable(capabilityId: string, enabled: boolean): void { - this.setPolicy(capabilityId, { modelInvocable: enabled }); - } - - setUserInvocable(capabilityId: string, enabled: boolean): void { - this.setPolicy(capabilityId, { userInvocable: enabled }); - } - - setEventPolicy(capabilityId: string, policy: EventPolicy): void { - this.setPolicy(capabilityId, { eventPolicy: policy }); - } - - simulateEvent(): void { - const selected = this.requireSelected(); - if (selected.observation.status !== "online") { - this.setView({ notice: "Enable the connection before emitting an event" }); - return; - } - const capabilityId = Object.keys(selected.instance.bindings)[0]; - if (capabilityId === undefined) { - this.setView({ notice: "Selected connection has no capability" }); - return; - } - const event: ExternalConnectionEvent = { - eventId: "fixture-event-" + this.sequence, - connectionId: selected.instance.id, - capabilityId, - generation: selected.observation.generation, - occurredAt: this.now(), - kind: capabilityId === "device.control" ? "device.notification" : "workspace.changed", - payload: capabilityId === "device.control" - ? { title: "Build finished", body: "Fixture device received a notification" } - : { path: "/workspace/demo/src/index.ts", change: "modified" }, - dedupeKey: "fixture-event-key-" + this.sequence, - source: "mock-adapter", - }; - this.sequence += 1; - this.mutate({ type: "event", connectionId: selected.instance.id, event }); - } - - invokeFirstCapability(): void { - this.invokeFirst(false); - } - - approveFirstCapability(): void { - this.invokeFirst(true); - } - - restoreSnapshot(snapshot: MtmConnectSnapshot): void { - if (this.registry === undefined) { - this.setView({ notice: "Remote snapshots are owned by the Host" }); - return; - } - try { - this.registry.restoreSnapshot(snapshot); - } catch (error) { - this.setView({ notice: error instanceof Error ? error.message : String(error) }); - } - } - - dispose(): void { - if (this.disposed) return; - this.disposed = true; - this.abortController.abort(); - this.unsubscribeRegistry(); - this.registry?.dispose(); - this.listeners.clear(); - } - - private async loadSnapshot(): Promise { - if (this.transport === undefined || this.disposed) return; - try { - const snapshot = await this.transport.snapshot(this.abortController.signal); - this.adoptSnapshot(snapshot, { loading: false, notice: undefined }); - } catch (error) { - if (!this.disposed) this.setView({ loading: false, notice: error instanceof Error ? error.message : String(error) }); - } - } - - private setPolicy(capabilityId: string, patch: Parameters[2]): void { - this.withSelected((connectionId) => { this.mutate({ type: "set-policy", connectionId, capabilityId, patch }); }); - } - - private invokeFirst(approved: boolean): void { - let selected: ReturnType; - try { - selected = this.requireSelected(); - } catch (error) { - this.setView({ notice: error instanceof Error ? error.message : String(error) }); - return; - } - const capabilityId = Object.keys(selected.instance.bindings)[0]; - if (capabilityId === undefined) { - this.setView({ notice: "Selected connection has no capability" }); - return; - } - const adapter = this.view.snapshot.adapters.find((candidate) => candidate.id === selected.instance.adapterId); - const capability = adapter?.capabilities.find((candidate) => candidate.id === capabilityId); - const operation = capability?.operations[0]; - if (capability === undefined || operation === undefined) { - this.setView({ notice: "Selected connection has no operation" }); - return; - } - const input: JsonObject = operation.id === "input.tap" ? { x: 420, y: 880 } : { path: selected.instance.config.root ?? "/workspace/demo" }; - const request: MtmConnectInvocationRequest = { - connectionId: selected.instance.id, - generation: selected.observation.generation, - capabilityId, - operationId: operation.id, - input, - actor: "user", - approved, - }; - if (this.transport === undefined) { - const registry = this.registry; - if (registry === undefined) return; - void registry.invoke(request) - .then((result) => { this.setView({ lastInvocation: result, notice: result.ok ? result.summary : result.message }); }) - .catch((error) => { this.setView({ notice: error instanceof Error ? error.message : String(error) }); }); - return; - } - void this.enqueue(async () => { - try { - const result = await this.transport!.invoke(request, this.abortController.signal); - this.setView({ lastInvocation: result, notice: result.ok ? result.summary : result.message }); - } catch (error) { - this.setView({ notice: error instanceof Error ? error.message : String(error) }); - } - }); - } - - private mutate(mutation: MtmConnectMutation): void { - if (this.transport === undefined) { - try { - const response = this.registry!.applyMutation(mutation); - const selected = mutation.type === "create" ? response.snapshot.connections.at(-1)?.instance.id : undefined; - this.adoptSnapshot(response.snapshot, { - ...(selected === undefined ? {} : { selectedConnectionId: selected }), - ...(response.projection === undefined ? {} : { lastProjection: response.projection, notice: projectionMessage(response.projection) }), - }); - } catch (error) { - this.setView({ notice: error instanceof Error ? error.message : String(error) }); - } - return; - } - void this.enqueue(async () => { - try { - const response = await this.transport!.mutate(mutation, this.abortController.signal); - const selected = mutation.type === "create" ? response.snapshot.connections.at(-1)?.instance.id : undefined; - this.adoptSnapshot(response.snapshot, { - ...(selected === undefined ? {} : { selectedConnectionId: selected }), - ...(response.projection === undefined ? {} : { lastProjection: response.projection, notice: projectionMessage(response.projection) }), - }); - } catch (error) { - this.setView({ notice: error instanceof Error ? error.message : String(error) }); - } - }); - } - - private enqueue(task: () => Promise): Promise { - const next = this.mutationTail.then(task, task); - this.mutationTail = next.then(() => undefined, () => undefined); - return next; - } - - private withSelected(action: (connectionId: string) => void): void { - try { - action(this.selectedId()); - } catch (error) { - this.setView({ notice: error instanceof Error ? error.message : String(error) }); - } - } - - private selectedId(): string { - const id = this.view.selectedConnectionId ?? this.view.snapshot.connections[0]?.instance.id; - if (id === undefined) throw new Error("Create a connection before using the control panel"); - return id; - } - - private requireSelected() { - const id = this.selectedId(); - const record = this.view.snapshot.connections.find((candidate) => candidate.instance.id === id); - if (record === undefined) throw new Error("Selected connection is no longer available"); - return record; - } - - private adoptSnapshot(snapshotInput: MtmConnectSnapshot | undefined, patch: Partial = {}): void { - if (snapshotInput === undefined || this.disposed) return; - const snapshot = validateSnapshot(snapshotInput); - if (snapshot.revision < this.view.snapshot.revision) return; - const selected = patch.selectedConnectionId !== undefined - ? patch.selectedConnectionId - : this.view.selectedConnectionId !== undefined && snapshot.connections.some((record) => record.instance.id === this.view.selectedConnectionId) - ? this.view.selectedConnectionId - : snapshot.connections[0]?.instance.id; - this.setView({ ...patch, snapshot, selectedConnectionId: selected }); - } - - private setView(patch: Partial): void { - if (this.disposed) return; - this.view = { ...this.view, ...patch }; - for (const listener of [...this.listeners]) listener(); - } -} - -function projectionMessage(projection: EventProjection): string { - if (projection.disposition === "dropped") return "Event dropped: " + (projection.reason ?? "policy"); - if (projection.disposition === "observed") return "Event observed without a model turn"; - if (projection.disposition === "queued") return "Event queued for the next admitted step"; - if (projection.disposition === "wake-agent") return "Event would wake the agent under this policy"; - return "Event is waiting for user approval"; -} diff --git a/packages/mtmharness/src/features/connect/client/styles.test.ts b/packages/mtmharness/src/features/connect/client/styles.test.ts deleted file mode 100644 index 2db8ede..0000000 --- a/packages/mtmharness/src/features/connect/client/styles.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { MTM_CONNECT_CSS } from "./styles.ts"; - -describe("mtm-connect stylesheet", () => { - it("uses DSH semantic tokens without global theme ownership", () => { - expect(MTM_CONNECT_CSS).toContain("--dsw-alias-label-primary"); - expect(MTM_CONNECT_CSS).toContain("--dsw-alias-state-business-primary"); - expect(MTM_CONNECT_CSS).toContain("@container (min-width: 620px)"); - expect(MTM_CONNECT_CSS).toContain(".mtm-modal"); - expect(MTM_CONNECT_CSS).toContain(".mtmc-summary-line"); - expect(MTM_CONNECT_CSS).not.toMatch(/:root\b/); - expect(MTM_CONNECT_CSS).not.toMatch(/--mtmc-/); - expect(MTM_CONNECT_CSS).not.toMatch(/#[0-9a-f]{3,8}\b/i); - expect(MTM_CONNECT_CSS).not.toContain("body[data-ds-dark-theme]"); - expect(MTM_CONNECT_CSS).toContain("[data-mtm-connect] .mtmc-summary-line"); - expect(MTM_CONNECT_CSS).toContain("max-height: calc(100vh - 32px)"); - expect(MTM_CONNECT_CSS).toContain("overflow-y: auto"); - }); -}); diff --git a/packages/mtmharness/src/features/connect/client/styles.ts b/packages/mtmharness/src/features/connect/client/styles.ts deleted file mode 100644 index 14cefb4..0000000 --- a/packages/mtmharness/src/features/connect/client/styles.ts +++ /dev/null @@ -1,424 +0,0 @@ -export const MTM_CONNECT_CSS = String.raw` -.mtm-modal { - width: min(760px, calc(100vw - 32px)); - max-height: calc(100vh - 32px); - gap: 0; - padding-bottom: 16px; - border-radius: 16px; -} - -.mtm-modal-content { - min-height: 0; - max-height: calc(100vh - 80px); - overflow-y: auto; - scrollbar-gutter: stable; -} - -.mtm-trigger-rail { - width: 36px; - min-width: 36px; - height: 28px; - padding: 0 2px; - font-size: 11px; - line-height: 14px; -} - -[data-mtm-connect] { - width: 100%; - min-width: 0; - box-sizing: border-box; - container-type: inline-size; - color: var(--dsw-alias-label-primary); - font-family: var(--dsw-font-family, sans-serif); - font-size: 12px; - line-height: 18px; -} - -[data-mtm-connect] .mtmc-header { - display: flex; - align-items: flex-start; - gap: 12px; - margin-bottom: 10px; -} - -[data-mtm-connect] .mtmc-header h3 { - margin: 0; - font-size: 15px; - line-height: 22px; - font-weight: 700; -} - -[data-mtm-connect] .mtmc-header p { - margin: 2px 0 0; - color: var(--dsw-alias-label-tertiary); - font-size: 11px; - line-height: 16px; -} - -[data-mtm-connect] .mtmc-summary-line { - display: flex; - flex-wrap: wrap; - gap: 4px 16px; - margin-bottom: 12px; - border-top: 1px solid var(--dsw-alias-border-l2); - border-bottom: 1px solid var(--dsw-alias-border-l2); - padding: 7px 0; - color: var(--dsw-alias-label-tertiary); - font-size: 10px; - line-height: 14px; -} - -[data-mtm-connect] .mtmc-summary-line strong { - color: var(--dsw-alias-label-primary); - font-size: 12px; - font-weight: 600; -} - -[data-mtm-connect] .mtmc-layout { - display: grid; - min-width: 0; - grid-template-columns: minmax(0, 1fr); - gap: 10px; -} - -[data-mtm-connect] .mtmc-section { - min-width: 0; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 8px; - background: var(--dsw-alias-bg-layer-3); -} - -[data-mtm-connect] .mtmc-section-header { - display: flex; - min-height: 38px; - align-items: center; - justify-content: space-between; - gap: 8px; - border-bottom: 1px solid var(--dsw-alias-border-l2); - padding: 0 10px; -} - -[data-mtm-connect] .mtmc-section-header h4 { - margin: 0; - font-size: 11px; - line-height: 16px; - font-weight: 700; -} - -[data-mtm-connect] .mtmc-connection-list { - display: flex; - max-height: 250px; - flex-direction: column; - gap: 2px; - overflow-y: auto; - padding: 6px; -} - -[data-mtm-connect] .mtmc-connection { - display: flex; - min-width: 0; - align-items: center; - gap: 8px; - border: 1px solid transparent; - border-radius: 6px; - padding: 8px; - background: transparent; - color: inherit; - cursor: pointer; - font: inherit; - text-align: left; -} - -[data-mtm-connect] .mtmc-connection:hover, -[data-mtm-connect] .mtmc-connection-selected { - border-color: var(--dsw-alias-border-l2); - background: var(--dsw-alias-interactive-bg-hover); -} - -[data-mtm-connect] .mtmc-connection:focus-visible, -[data-mtm-connect] .mtmc-action-button:focus-visible, -[data-mtm-connect] .mtmc-field select:focus-visible, -[data-mtm-connect] .mtmc-capability-settings summary:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); - outline-offset: 2px; -} - -[data-mtm-connect] .mtmc-connection-copy { - min-width: 0; - flex: 1; -} - -[data-mtm-connect] .mtmc-connection-copy strong { - display: block; - overflow: hidden; - font-size: 11px; - line-height: 16px; - text-overflow: ellipsis; - white-space: nowrap; -} - -[data-mtm-connect] .mtmc-connection-copy small { - display: block; - overflow: hidden; - margin-top: 2px; - color: var(--dsw-alias-label-tertiary); - font-size: 10px; - line-height: 14px; - text-overflow: ellipsis; - white-space: nowrap; -} - -[data-mtm-connect] .mtmc-status { - display: inline-flex; - min-height: 18px; - height: 18px; - align-items: center; - border-radius: 999px; - padding: 0 6px; - font-size: 9px; - line-height: 14px; - font-weight: 700; - white-space: nowrap; -} - -[data-mtm-connect] .mtmc-status-online { - background: var(--dsw-alias-state-success-tertiary); - color: var(--dsw-alias-state-success-primary); -} - -[data-mtm-connect] .mtmc-status-configured, -[data-mtm-connect] .mtmc-status-connecting, -[data-mtm-connect] .mtmc-status-authorizing, -[data-mtm-connect] .mtmc-status-enrolled { - background: var(--dsw-alias-state-warn-tertiary); - color: var(--dsw-alias-state-warn-label); -} - -[data-mtm-connect] .mtmc-status-offline, -[data-mtm-connect] .mtmc-status-degraded { - background: var(--dsw-alias-bg-module-platform); - color: var(--dsw-alias-label-secondary); -} - -[data-mtm-connect] .mtmc-status-revoked, -[data-mtm-connect] .mtmc-status-unavailable { - background: var(--dsw-alias-interactive-bg-hover-danger); - color: var(--dsw-alias-state-error-primary); -} - -[data-mtm-connect] .mtmc-detail { - min-width: 0; - padding: 11px; -} - -[data-mtm-connect] .mtmc-detail-title { - display: flex; - min-width: 0; - align-items: flex-start; - justify-content: space-between; - gap: 8px; -} - -[data-mtm-connect] .mtmc-detail-title strong { - display: block; - overflow: hidden; - font-size: 13px; - line-height: 20px; - text-overflow: ellipsis; - white-space: nowrap; -} - -[data-mtm-connect] .mtmc-detail-title small { - color: var(--dsw-alias-label-tertiary); - font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, monospace); - font-size: 10px; - line-height: 14px; -} - -[data-mtm-connect] .mtmc-detail-meta { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 0 12px; - margin: 9px 0; -} - -[data-mtm-connect] .mtmc-meta-row { - display: flex; - min-width: 0; - align-items: baseline; - justify-content: space-between; - gap: 8px; - border-bottom: 1px solid var(--dsw-alias-border-l2); - padding: 5px 0; -} - -[data-mtm-connect] .mtmc-meta-row span { - color: var(--dsw-alias-label-tertiary); - font-size: 10px; - line-height: 14px; -} - -[data-mtm-connect] .mtmc-meta-row strong { - min-width: 0; - overflow: hidden; - font-size: 11px; - line-height: 16px; - text-overflow: ellipsis; - white-space: nowrap; -} - -[data-mtm-connect] .mtmc-actions { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin: 9px 0; -} - -[data-mtm-connect] .mtmc-action-button { - flex: none; -} - -[data-mtm-connect] .mtmc-action-button-danger { - border-color: var(--dsw-alias-state-error-secondary); - color: var(--dsw-alias-state-error-primary); -} - -[data-mtm-connect] .mtmc-action-button-danger:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover-danger); -} - -[data-mtm-connect] .mtmc-subheading { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - margin: 12px 0 6px; -} - -[data-mtm-connect] .mtmc-subheading h5 { - margin: 0; - font-size: 11px; - line-height: 16px; -} - -[data-mtm-connect] .mtmc-subheading span { - color: var(--dsw-alias-label-tertiary); - font-size: 10px; - line-height: 14px; -} - -[data-mtm-connect] .mtmc-capability { - border-top: 1px solid var(--dsw-alias-border-l2); - padding: 8px 0; -} - -[data-mtm-connect] .mtmc-capability-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 8px; -} - -[data-mtm-connect] .mtmc-capability-heading strong { - min-width: 0; - font-size: 11px; - line-height: 16px; -} - -[data-mtm-connect] .mtmc-capability-heading small { - color: var(--dsw-alias-label-tertiary); - font-size: 10px; - line-height: 14px; - text-align: right; -} - -[data-mtm-connect] .mtmc-capability-settings { - margin-top: 5px; -} - -[data-mtm-connect] .mtmc-capability-settings summary { - width: fit-content; - color: var(--dsw-alias-label-tertiary); - cursor: pointer; - font-size: 10px; - line-height: 14px; -} - -[data-mtm-connect] .mtmc-capability-controls { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 5px 10px; - margin-top: 8px; -} - -[data-mtm-connect] .mtmc-check { - display: flex; - min-width: 0; - align-items: center; - gap: 5px; - color: var(--dsw-alias-label-secondary); - font-size: 10px; - line-height: 14px; -} - -[data-mtm-connect] .mtmc-check input { - margin: 0; - accent-color: var(--dsw-alias-state-business-primary); -} - -[data-mtm-connect] .mtmc-field { - display: flex; - min-width: 0; - flex-direction: column; - gap: 4px; - color: var(--dsw-alias-label-tertiary); - font-size: 10px; - line-height: 14px; -} - -[data-mtm-connect] .mtmc-field select { - min-height: 27px; - min-width: 0; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 5px; - padding: 0 6px; - background: var(--dsw-alias-bg-layer-1); - color: var(--dsw-alias-label-primary); - font: inherit; - font-size: 10px; - line-height: 14px; -} - -[data-mtm-connect] .mtmc-notice { - margin: 9px 0 0; - border-left: 3px solid var(--dsw-alias-state-business-primary); - padding: 6px 8px; - background: var(--dsw-alias-state-business-tertiary); - color: var(--dsw-alias-label-primary); - font-size: 10px; - line-height: 14px; -} - -[data-mtm-connect] .mtmc-empty { - padding: 14px 10px; - color: var(--dsw-alias-label-tertiary); - font-size: 11px; - line-height: 16px; -} - -@container (min-width: 620px) { - [data-mtm-connect] .mtmc-layout { - grid-template-columns: minmax(190px, .72fr) minmax(0, 1.28fr); - } - - [data-mtm-connect] .mtmc-detail-meta { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (prefers-reduced-motion: reduce) { - [data-mtm-connect] * { - scroll-behavior: auto; - } -} -`.trim(); diff --git a/packages/mtmharness/src/features/connect/contract/adapter.ts b/packages/mtmharness/src/features/connect/contract/adapter.ts deleted file mode 100644 index fb9be06..0000000 --- a/packages/mtmharness/src/features/connect/contract/adapter.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { isJsonValue, isRecord } from "./json.ts"; - -export type AdapterStatus = "installed" | "unavailable"; -export type AdapterKind = "mock-world" | "mock-device" | "unavailable"; -export type CapabilityRole = "primary-world" | "additive-capability"; -export type OperationKind = "one-shot" | "declarative"; -export type OperationSideEffect = "read" | "write"; -export type SetupMethodKind = "mock" | "manual" | "device-code" | "oauth"; - -export interface SetupMethodDescriptor { - readonly id: string; - readonly label: string; - readonly kind: SetupMethodKind; - readonly status: "available" | "unavailable"; -} - -export interface OperationDescriptor { - readonly id: string; - readonly label: string; - readonly kind: OperationKind; - readonly sideEffect: OperationSideEffect; - readonly requiresApproval: boolean; -} - -export interface CapabilityLimits { - readonly maxInputBytes: number; - readonly maxOutputBytes: number; -} - -export interface CapabilityDescriptor { - readonly id: string; - readonly version: string; - readonly label: string; - readonly role: CapabilityRole; - readonly eventKinds: readonly string[]; - readonly operations: readonly OperationDescriptor[]; - readonly limits: CapabilityLimits; - readonly supportedTargets: readonly string[]; -} - -export interface AdapterDescriptor { - readonly id: string; - readonly version: string; - readonly label: string; - readonly summary: string; - readonly status: AdapterStatus; - readonly kind: AdapterKind; - readonly setupMethods: readonly SetupMethodDescriptor[]; - readonly capabilities: readonly CapabilityDescriptor[]; - readonly availabilityNote?: string; -} - -const ID_PATTERN = /^[a-z][a-z0-9.-]{1,63}$/; - -function exactKeys(value: Record, allowed: readonly string[], label: string): void { - const allowedSet = new Set(allowed); - for (const key of Object.keys(value)) { - if (!allowedSet.has(key)) throw new Error(label + " contains unsupported field: " + key); - } -} - -function stringValue(value: unknown, label: string, pattern = /.+/): string { - if (typeof value !== "string" || !pattern.test(value)) throw new Error(label + " must be a non-empty string"); - return value; -} - -function booleanValue(value: unknown, label: string): boolean { - if (typeof value !== "boolean") throw new Error(label + " must be a boolean"); - return value; -} - -function positiveInteger(value: unknown, label: string): number { - if (!Number.isInteger(value) || (value as number) < 1) throw new Error(label + " must be a positive integer"); - return value as number; -} - -function stringList(value: unknown, label: string): readonly string[] { - if (!Array.isArray(value)) throw new Error(label + " must be an array"); - return value.map((item, index) => stringValue(item, label + "[" + index + "]")); -} - -function validateSetupMethod(value: unknown, index: number): SetupMethodDescriptor { - if (!isRecord(value)) throw new Error("setupMethods[" + index + "] must be an object"); - exactKeys(value, ["id", "label", "kind", "status"], "setupMethods[" + index + "]"); - const kind = value.kind; - if (kind !== "mock" && kind !== "manual" && kind !== "device-code" && kind !== "oauth") throw new Error("invalid setup method kind"); - const status = value.status; - if (status !== "available" && status !== "unavailable") throw new Error("invalid setup method status"); - return { - id: stringValue(value.id, "setup method id", ID_PATTERN), - label: stringValue(value.label, "setup method label"), - kind, - status, - }; -} - -function validateOperation(value: unknown, index: number): OperationDescriptor { - if (!isRecord(value)) throw new Error("operation[" + index + "] must be an object"); - exactKeys(value, ["id", "label", "kind", "sideEffect", "requiresApproval"], "operation[" + index + "]"); - const kind = value.kind; - if (kind !== "one-shot" && kind !== "declarative") throw new Error("invalid operation kind"); - const sideEffect = value.sideEffect; - if (sideEffect !== "read" && sideEffect !== "write") throw new Error("invalid operation side effect"); - const requiresApproval = booleanValue(value.requiresApproval, "operation requiresApproval"); - if (sideEffect === "write" && !requiresApproval) throw new Error("write operations must require approval"); - return { - id: stringValue(value.id, "operation id", ID_PATTERN), - label: stringValue(value.label, "operation label"), - kind, - sideEffect, - requiresApproval, - }; -} - -function validateCapability(value: unknown, index: number): CapabilityDescriptor { - if (!isRecord(value)) throw new Error("capability[" + index + "] must be an object"); - exactKeys(value, ["id", "version", "label", "role", "eventKinds", "operations", "limits", "supportedTargets"], "capability[" + index + "]"); - const role = value.role; - if (role !== "primary-world" && role !== "additive-capability") throw new Error("invalid capability role"); - if (!Array.isArray(value.operations)) throw new Error("capability operations must be an array"); - if (!isRecord(value.limits)) throw new Error("capability limits must be an object"); - exactKeys(value.limits, ["maxInputBytes", "maxOutputBytes"], "capability limits"); - return { - id: stringValue(value.id, "capability id", ID_PATTERN), - version: stringValue(value.version, "capability version"), - label: stringValue(value.label, "capability label"), - role, - eventKinds: stringList(value.eventKinds, "capability eventKinds"), - operations: value.operations.map(validateOperation), - limits: { - maxInputBytes: positiveInteger(value.limits.maxInputBytes, "capability maxInputBytes"), - maxOutputBytes: positiveInteger(value.limits.maxOutputBytes, "capability maxOutputBytes"), - }, - supportedTargets: stringList(value.supportedTargets, "capability supportedTargets"), - }; -} - -export function validateAdapterDescriptor(value: unknown): AdapterDescriptor { - if (!isRecord(value)) throw new Error("adapter descriptor must be an object"); - exactKeys(value, ["id", "version", "label", "summary", "status", "kind", "setupMethods", "capabilities", "availabilityNote"], "adapter descriptor"); - const status = value.status; - if (status !== "installed" && status !== "unavailable") throw new Error("invalid adapter status"); - const kind = value.kind; - if (kind !== "mock-world" && kind !== "mock-device" && kind !== "unavailable") throw new Error("invalid adapter kind"); - if (!Array.isArray(value.setupMethods) || !Array.isArray(value.capabilities)) throw new Error("adapter setupMethods and capabilities must be arrays"); - if (value.availabilityNote !== undefined) stringValue(value.availabilityNote, "adapter availabilityNote"); - const setupMethods = value.setupMethods.map(validateSetupMethod); - const capabilities = value.capabilities.map(validateCapability); - const setupIds = new Set(); - for (const setup of setupMethods) { - if (setupIds.has(setup.id)) throw new Error("duplicate setup method id: " + setup.id); - setupIds.add(setup.id); - } - const capabilityIds = new Set(); - for (const capability of capabilities) { - if (capabilityIds.has(capability.id)) throw new Error("duplicate capability id: " + capability.id); - capabilityIds.add(capability.id); - const operationIds = new Set(); - for (const operation of capability.operations) { - if (operationIds.has(operation.id)) throw new Error("duplicate operation id: " + operation.id); - operationIds.add(operation.id); - } - } - const descriptor: AdapterDescriptor = { - id: stringValue(value.id, "adapter id", ID_PATTERN), - version: stringValue(value.version, "adapter version"), - label: stringValue(value.label, "adapter label"), - summary: stringValue(value.summary, "adapter summary"), - status, - kind, - setupMethods, - capabilities, - ...(value.availabilityNote === undefined ? {} : { availabilityNote: stringValue(value.availabilityNote, "adapter availabilityNote") }), - }; - if (!isJsonValue(descriptor)) throw new Error("adapter descriptor must be JSON-safe"); - return descriptor; -} diff --git a/packages/mtmharness/src/features/connect/contract/connection.ts b/packages/mtmharness/src/features/connect/contract/connection.ts deleted file mode 100644 index 46cd7b4..0000000 --- a/packages/mtmharness/src/features/connect/contract/connection.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { AdapterDescriptor, CapabilityDescriptor } from "./adapter.ts"; -import type { EventPolicy, EventRecord, EventProjection, ExternalConnectionEvent } from "./event.ts"; -import type { MtmModelProfileRef } from "./control-plane.ts"; -import { assertPublicConfig, type JsonObject } from "./json.ts"; - -export type DesiredConnectionState = "disabled" | "enabled"; -export type ObservedConnectionState = "configured" | "authorizing" | "enrolled" | "connecting" | "online" | "degraded" | "offline" | "revoked"; -export type BindingScope = "profile" | "sandbox" | "session"; - -export interface CapabilityBinding { - readonly capabilityId: string; - readonly enabled: boolean; - readonly modelInvocable: boolean; - readonly userInvocable: boolean; - readonly eventPolicy: EventPolicy; -} - -export interface WorldBinding { - readonly capabilityId: string; - readonly scope: BindingScope; - readonly status: "selected" | "not-selected"; -} - -export interface ConnectionInstance { - readonly id: string; - readonly ownerId: string; - readonly adapterId: string; - readonly label: string; - readonly config: JsonObject; - readonly desired: DesiredConnectionState; - readonly bindings: Readonly>; - readonly worldBinding?: WorldBinding; - readonly fixture: boolean; - readonly createdAt: number; - readonly updatedAt: number; -} - -export interface ConnectionObservation { - readonly status: ObservedConnectionState; - readonly generation: number; - readonly channelId?: string; - readonly lastSeenAt?: number; - readonly expiresAt?: number; - readonly lastError?: { readonly code: string; readonly message: string }; -} - -export interface ConnectionRecord { - readonly instance: ConnectionInstance; - readonly observation: ConnectionObservation; -} - -export interface MtmConnectSnapshot { - readonly schemaVersion: 2; - readonly revision: number; - readonly controlRevision?: number; - readonly ownerId: string; - readonly adapters: readonly AdapterDescriptor[]; - readonly connections: readonly ConnectionRecord[]; - readonly activeModelProfile: MtmModelProfileRef | null; - readonly eventHistory: readonly EventRecord[]; - readonly updatedAt: number; -} - -export type CapabilityInvocationResult = - | { - readonly ok: true; - readonly simulated: boolean; - readonly adapterId: string; - readonly connectionId: string; - readonly generation: number; - readonly capabilityId: string; - readonly operationId: string; - readonly summary: string; - readonly data: JsonObject; - } - | { - readonly ok: false; - readonly code: "connection-not-found" | "connection-offline" | "stale-generation" | "capability-not-found" | "capability-disabled" | "policy-denied" | "approval-required" | "input-too-large" | "output-too-large" | "adapter-unavailable" | "unsupported-operation" | "invalid-input"; - readonly message: string; - }; - -export type MtmConnectMutation = - | { readonly type: "create"; readonly adapterId: string; readonly label: string; readonly config: JsonObject; readonly scope?: BindingScope } - | { readonly type: "enable"; readonly connectionId: string } - | { readonly type: "disable"; readonly connectionId: string } - | { readonly type: "revoke"; readonly connectionId: string } - | { readonly type: "reconnect"; readonly connectionId: string } - | { readonly type: "set-policy"; readonly connectionId: string; readonly capabilityId: string; readonly patch: Partial> } - | { readonly type: "event"; readonly connectionId: string; readonly event: ExternalConnectionEvent }; - -export interface MtmConnectInvocationRequest { - readonly connectionId: string; - readonly generation: number; - readonly capabilityId: string; - readonly operationId: string; - readonly input: JsonObject; - readonly actor: "model" | "user"; - readonly approved?: boolean; -} - -export interface ConnectionSeed { - readonly id: string; - readonly label: string; - readonly config: JsonObject; - readonly fixture?: boolean; - readonly scope?: BindingScope; -} - -export function defaultBindings(adapter: AdapterDescriptor): Readonly> { - const bindings: Record = {}; - for (const capability of adapter.capabilities) { - bindings[capability.id] = { - capabilityId: capability.id, - enabled: true, - modelInvocable: capability.role === "primary-world" ? false : true, - userInvocable: true, - eventPolicy: "observe", - }; - } - return bindings; -} - -export function emptySnapshot(ownerId = "unknown"): MtmConnectSnapshot { - return { schemaVersion: 2, revision: 0, ownerId, adapters: [], connections: [], activeModelProfile: null, eventHistory: [], updatedAt: 0 }; -} - -export function createConnectionRecord( - ownerId: string, - adapter: AdapterDescriptor, - seed: ConnectionSeed, - now: number, -): ConnectionRecord { - assertPublicConfig(seed.config); - const primary = adapter.capabilities.find((capability) => capability.role === "primary-world"); - return { - instance: { - id: seed.id, - ownerId, - adapterId: adapter.id, - label: seed.label, - config: seed.config, - desired: "disabled", - bindings: defaultBindings(adapter), - ...(primary === undefined ? {} : { - worldBinding: { capabilityId: primary.id, scope: seed.scope ?? "sandbox", status: "selected" }, - }), - fixture: seed.fixture ?? true, - createdAt: now, - updatedAt: now, - }, - observation: { status: "configured", generation: 0 }, - }; -} - -export function adapterCapability(adapter: AdapterDescriptor, capabilityId: string): CapabilityDescriptor | undefined { - return adapter.capabilities.find((capability) => capability.id === capabilityId); -} diff --git a/packages/mtmharness/src/features/connect/contract/control-plane.ts b/packages/mtmharness/src/features/connect/contract/control-plane.ts deleted file mode 100644 index 30aaa53..0000000 --- a/packages/mtmharness/src/features/connect/contract/control-plane.ts +++ /dev/null @@ -1,290 +0,0 @@ -import type { JsonObject } from "./json.ts"; - -export const MTM_CONTROL_CONTRACT_VERSION = 2 as const; -export const MTM_CONTROL_MAX_PUBLIC_CONFIG_BYTES = 8 * 1024; - -export type MtmControlObservedStatus = "configured" | "authorizing" | "enrolled" | "connecting" | "online" | "degraded" | "offline" | "stale" | "revoked"; -export type MtmControlInstallationStatus = "active" | "expired" | "revoked"; -export type MtmControlEventPolicy = "observe" | "inject-next" | "wake-agent" | "require-approval" | "disabled"; - -export interface MtmControlScope { - readonly sandboxId: string; - readonly workspaceId: string; - readonly owner: { readonly issuer: string; readonly subject: string }; -} - -/** - * Secret-free reference to one tenant-owned model configuration revision. - * Tenant authorization is established by the control authority before this - * reference crosses the trusted Host bridge; mtmharness treats it as opaque. - */ -export interface MtmModelProfileRef { - readonly tenantId: string; - readonly profileId: string; - readonly revision: number; -} - -export interface MtmControlOperationDescriptor { - readonly operationId: string; - readonly sideEffect: "read" | "write"; - readonly requiresApproval: boolean; -} - -export interface MtmControlCapabilityDescriptor { - readonly capabilityId: string; - readonly version: string; - readonly role: "primary-world" | "additive-capability"; - readonly operations: readonly MtmControlOperationDescriptor[]; -} - -export interface MtmControlAdapterDescriptor { - readonly adapterId: string; - readonly version: string; - readonly label: string; - readonly available: boolean; - readonly capabilities: readonly MtmControlCapabilityDescriptor[]; -} - -export interface MtmControlCapabilityPolicy { - readonly capabilityId: string; - readonly enabled: boolean; - readonly modelInvocable: boolean; - readonly userInvocable: boolean; - readonly eventPolicy: MtmControlEventPolicy; -} - -export interface MtmControlDesiredWorld { - readonly worldId: string; - readonly adapterId: string; - readonly config: JsonObject; - readonly enabled: boolean; - readonly capabilities: Readonly>; -} - -export interface MtmControlObservedWorld { - readonly worldId: string; - readonly adapterId: string; - readonly status: MtmControlObservedStatus; - readonly generation: number; - readonly channelId?: string; - readonly lastSeenAt?: number; - readonly lastError?: { readonly code: string; readonly message: string }; -} - -export interface MtmControlInstallation { - readonly installationId: string; - readonly daemonId: string; - readonly generation: number; - readonly status: MtmControlInstallationStatus; - readonly boundAt: number; - readonly heartbeatAt: number; - readonly expiresAt: number; - readonly revokedAt?: number; -} - -export interface MtmControlSnapshot { - readonly contractVersion: typeof MTM_CONTROL_CONTRACT_VERSION; - readonly scope: MtmControlScope; - readonly revision: number; - readonly adapters: readonly MtmControlAdapterDescriptor[]; - readonly desiredWorlds: readonly MtmControlDesiredWorld[]; - readonly observedWorlds: readonly MtmControlObservedWorld[]; - readonly installation: MtmControlInstallation | null; - readonly activeModelProfile: MtmModelProfileRef | null; -} - -export function cloneMtmControlSnapshot(snapshot: MtmControlSnapshot): MtmControlSnapshot { - validateMtmControlSnapshot(snapshot); - return JSON.parse(JSON.stringify(snapshot)) as MtmControlSnapshot; -} - -export function validateMtmControlSnapshot(value: unknown): asserts value is MtmControlSnapshot { - if (!isRecord(value)) throw new Error("mtm control snapshot must be an object"); - exactKeys(value, ["contractVersion", "scope", "revision", "adapters", "desiredWorlds", "observedWorlds", "installation", "activeModelProfile"], "mtm control snapshot"); - if (value.contractVersion !== MTM_CONTROL_CONTRACT_VERSION || !isNonNegativeInteger(value.revision)) throw new Error("mtm control snapshot version or revision is invalid"); - validateScope(value.scope); - if (!Array.isArray(value.adapters) || !Array.isArray(value.desiredWorlds) || !Array.isArray(value.observedWorlds)) throw new Error("mtm control snapshot collections are invalid"); - const adapters = value.adapters.map(validateAdapter); - const adapterIds = new Set(); - for (const adapter of adapters) { - if (adapterIds.has(adapter.adapterId)) throw new Error("mtm control adapter is duplicated"); - adapterIds.add(adapter.adapterId); - } - const desiredIds = new Set(); - const desiredById = new Map(); - for (const raw of value.desiredWorlds) { - const desired = validateDesiredWorld(raw); - if (desiredIds.has(desired.worldId) || !adapterIds.has(desired.adapterId)) throw new Error("mtm control desired world is invalid"); - desiredIds.add(desired.worldId); - desiredById.set(desired.worldId, desired); - } - const observedIds = new Set(); - for (const raw of value.observedWorlds) { - const observed = validateObservedWorld(raw); - const desired = desiredById.get(observed.worldId); - if (observedIds.has(observed.worldId) || desired === undefined || desired.adapterId !== observed.adapterId) throw new Error("mtm control observed world is invalid"); - observedIds.add(observed.worldId); - } - if (value.installation !== null) validateInstallation(value.installation); - if (value.activeModelProfile !== null) validateMtmModelProfileRef(value.activeModelProfile); - assertSecretFree(value); -} - -function validateScope(value: unknown): asserts value is MtmControlScope { - if (!isRecord(value) || !isRecord(value.owner)) throw new Error("mtm control scope is invalid"); - exactKeys(value, ["sandboxId", "workspaceId", "owner"], "mtm control scope"); - exactKeys(value.owner, ["issuer", "subject"], "mtm control owner"); - identifier(value.sandboxId, "sandbox id"); - identifier(value.workspaceId, "workspace id"); - text(value.owner.issuer, "owner issuer", 512); - text(value.owner.subject, "owner subject", 256); -} - -function validateAdapter(value: unknown): MtmControlAdapterDescriptor { - if (!isRecord(value) || !Array.isArray(value.capabilities)) throw new Error("mtm control adapter is invalid"); - exactKeys(value, ["adapterId", "version", "label", "available", "capabilities"], "mtm control adapter"); - const adapterId = identifier(value.adapterId, "adapter id"); - const version = text(value.version, "adapter version", 64); - const label = text(value.label, "adapter label", 128); - if (typeof value.available !== "boolean") throw new Error("mtm control adapter availability is invalid"); - const capabilityIds = new Set(); - const capabilities = value.capabilities.map((raw) => { - if (!isRecord(raw) || !Array.isArray(raw.operations)) throw new Error("mtm control capability is invalid"); - exactKeys(raw, ["capabilityId", "version", "role", "operations"], "mtm control capability"); - const capabilityId = identifier(raw.capabilityId, "capability id"); - if (capabilityIds.has(capabilityId)) throw new Error("mtm control capability is duplicated"); - capabilityIds.add(capabilityId); - const role: "primary-world" | "additive-capability" = raw.role === "primary-world" || raw.role === "additive-capability" - ? raw.role - : (() => { throw new Error("mtm control capability role is invalid"); })(); - const capabilityVersion = text(raw.version, "capability version", 64); - const operationIds = new Set(); - const operations = raw.operations.map((operation) => { - if (!isRecord(operation)) throw new Error("mtm control operation is invalid"); - exactKeys(operation, ["operationId", "sideEffect", "requiresApproval"], "mtm control operation"); - const operationId = identifier(operation.operationId, "operation id"); - if (operationIds.has(operationId)) throw new Error("mtm control operation is duplicated"); - operationIds.add(operationId); - const sideEffect: "read" | "write" = operation.sideEffect === "read" || operation.sideEffect === "write" - ? operation.sideEffect - : (() => { throw new Error("mtm control operation side effect is invalid"); })(); - if (typeof operation.requiresApproval !== "boolean" || (sideEffect === "write" && !operation.requiresApproval)) throw new Error("mtm control operation approval is invalid"); - return { operationId, sideEffect, requiresApproval: operation.requiresApproval }; - }); - return { capabilityId, version: capabilityVersion, role, operations }; - }); - if (capabilities.filter((capability) => capability.role === "primary-world").length !== 1) throw new Error("mtm control adapter must declare one primary world"); - return { adapterId, version, label, available: value.available, capabilities }; -} - -function validateDesiredWorld(value: unknown): MtmControlDesiredWorld { - if (!isRecord(value) || !isRecord(value.config) || !isRecord(value.capabilities)) throw new Error("mtm control desired world is invalid"); - exactKeys(value, ["worldId", "adapterId", "config", "enabled", "capabilities"], "mtm control desired world"); - const worldId = identifier(value.worldId, "world id"); - const adapterId = identifier(value.adapterId, "adapter id"); - if (typeof value.enabled !== "boolean") throw new Error("mtm control desired state is invalid"); - assertPublicConfig(value.config); - const capabilities: Record = {}; - for (const [key, raw] of Object.entries(value.capabilities)) { - identifier(key, "capability policy id"); - if (!isRecord(raw) || raw.capabilityId !== key || typeof raw.enabled !== "boolean" || typeof raw.modelInvocable !== "boolean" || typeof raw.userInvocable !== "boolean") throw new Error("mtm control capability policy is invalid"); - exactKeys(raw, ["capabilityId", "enabled", "modelInvocable", "userInvocable", "eventPolicy"], "mtm control capability policy"); - if (!isEventPolicy(raw.eventPolicy)) throw new Error("mtm control event policy is invalid"); - capabilities[key] = { capabilityId: key, enabled: raw.enabled, modelInvocable: raw.modelInvocable, userInvocable: raw.userInvocable, eventPolicy: raw.eventPolicy }; - } - return { worldId, adapterId, config: value.config as JsonObject, enabled: value.enabled, capabilities }; -} - -function validateObservedWorld(value: unknown): MtmControlObservedWorld { - if (!isRecord(value)) throw new Error("mtm control observed world is invalid"); - exactKeys(value, ["worldId", "adapterId", "status", "generation", "channelId", "lastSeenAt", "lastError"], "mtm control observed world"); - const worldId = identifier(value.worldId, "world id"); - const adapterId = identifier(value.adapterId, "adapter id"); - if (!isObservedStatus(value.status) || !isNonNegativeInteger(value.generation)) throw new Error("mtm control observed world is invalid"); - if (value.channelId !== undefined) identifier(value.channelId, "channel id"); - if (value.lastSeenAt !== undefined && !isNonNegativeInteger(value.lastSeenAt)) throw new Error("mtm control observed timestamp is invalid"); - if (value.status === "online" && (value.channelId === undefined || value.lastSeenAt === undefined)) throw new Error("mtm control online world is missing channel facts"); - if (value.status !== "online" && value.channelId !== undefined) throw new Error("mtm control offline world has a channel"); - if (value.lastError !== undefined) { - if (!isRecord(value.lastError)) throw new Error("mtm control observed error is invalid"); - exactKeys(value.lastError, ["code", "message"], "mtm control observed error"); - identifier(value.lastError.code, "observed error code"); - text(value.lastError.message, "observed error message", 240); - } - return value as unknown as MtmControlObservedWorld; -} - -export function validateMtmModelProfileRef(value: unknown): asserts value is MtmModelProfileRef { - if (!isRecord(value)) throw new Error("mtm model profile is invalid"); - exactKeys(value, ["tenantId", "profileId", "revision"], "mtm model profile"); - identifier(value.tenantId, "model profile tenant id"); - identifier(value.profileId, "model profile id"); - if (!isNonNegativeInteger(value.revision) || value.revision < 1) throw new Error("model profile revision is invalid"); -} - -function validateInstallation(value: unknown): void { - if (!isRecord(value)) throw new Error("mtm control installation is invalid"); - exactKeys(value, ["installationId", "daemonId", "generation", "status", "boundAt", "heartbeatAt", "expiresAt", "revokedAt"], "mtm control installation"); - identifier(value.installationId, "installation id"); - identifier(value.daemonId, "daemon id"); - if (!isNonNegativeInteger(value.generation) || !isNonNegativeInteger(value.boundAt) || !isNonNegativeInteger(value.heartbeatAt) || !isNonNegativeInteger(value.expiresAt)) throw new Error("mtm control installation timestamps are invalid"); - if (value.heartbeatAt < value.boundAt || value.expiresAt < value.heartbeatAt) throw new Error("mtm control installation timestamp order is invalid"); - if (value.status !== "active" && value.status !== "expired" && value.status !== "revoked") throw new Error("mtm control installation status is invalid"); - if (value.status === "revoked" && value.revokedAt === undefined) throw new Error("mtm control revoked timestamp is invalid"); - if (value.status !== "revoked" && value.revokedAt !== undefined) throw new Error("mtm control revoked timestamp is invalid"); - if (value.revokedAt !== undefined && !isNonNegativeInteger(value.revokedAt)) throw new Error("mtm control revoked timestamp is invalid"); -} - -function assertPublicConfig(value: unknown): asserts value is JsonObject { - assertSecretFree(value); - const serialized = JSON.stringify(value); - if (serialized === undefined || serialized.length > MTM_CONTROL_MAX_PUBLIC_CONFIG_BYTES) throw new Error("mtm control public config is too large"); -} - -function assertSecretFree(value: unknown): void { - if (value === null || typeof value === "number" || typeof value === "boolean") return; - if (typeof value === "string") { - if (/\bBearer\s+\S+|-----BEGIN [A-Z ]+-----|(?:access[_-]?token|refresh[_-]?token|api[_-]?key|client[_-]?secret)\s*[:=]\s*\S+/iu.test(value)) throw new Error("mtm control secret is not allowed"); - return; - } - if (Array.isArray(value)) { - value.forEach(assertSecretFree); - return; - } - if (!isRecord(value)) throw new Error("mtm control JSON is invalid"); - for (const [key, child] of Object.entries(value)) { - if (/(?:token|secret|password|credential|private[_ -]?key|privatekey|api[_ -]?key|apikey|authorization|cookie|passphrase|oauth|session)/iu.test(key)) throw new Error("mtm control secret is not allowed"); - assertSecretFree(child); - } -} - -function exactKeys(value: Record, keys: readonly string[], label: string): void { - const allowed = new Set(keys); - for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(label + " contains unsupported field: " + key); -} - -function identifier(value: unknown, label: string): string { - if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/u.test(value)) throw new Error(label + " is invalid"); - return value; -} - -function text(value: unknown, label: string, maxLength: number): string { - if (typeof value !== "string" || value.length === 0 || value.length > maxLength || /[\u0000-\u001f\u007f]/u.test(value)) throw new Error(label + " is invalid"); - return value; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isNonNegativeInteger(value: unknown): value is number { - return Number.isSafeInteger(value) && (value as number) >= 0; -} - -function isObservedStatus(value: unknown): value is MtmControlObservedStatus { - return value === "configured" || value === "authorizing" || value === "enrolled" || value === "connecting" || value === "online" || value === "degraded" || value === "offline" || value === "stale" || value === "revoked"; -} - -function isEventPolicy(value: unknown): value is MtmControlEventPolicy { - return value === "observe" || value === "inject-next" || value === "wake-agent" || value === "require-approval" || value === "disabled"; -} diff --git a/packages/mtmharness/src/features/connect/contract/event.test.ts b/packages/mtmharness/src/features/connect/contract/event.test.ts deleted file mode 100644 index f6fd456..0000000 --- a/packages/mtmharness/src/features/connect/contract/event.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { projectExternalEvent, validateExternalEvent } from "./event.ts"; -import type { ExternalConnectionEvent } from "./event.ts"; - -const fixture: ExternalConnectionEvent = { - eventId: "event-1", - connectionId: "connection-1", - capabilityId: "device.control", - generation: 1, - occurredAt: 1_700_000_000_000, - kind: "device.notification", - payload: { title: "Build finished" }, - dedupeKey: "connection-1:1", - source: "mock-device", -}; - -describe("external event contract", () => { - it("projects each explicit policy without invoking an agent", () => { - expect(projectExternalEvent(fixture, "observe", new Set()).disposition).toBe("observed"); - expect(projectExternalEvent(fixture, "inject-next", new Set()).disposition).toBe("queued"); - expect(projectExternalEvent(fixture, "wake-agent", new Set()).disposition).toBe("wake-agent"); - expect(projectExternalEvent(fixture, "require-approval", new Set()).disposition).toBe("approval-required"); - expect(projectExternalEvent(fixture, "disabled", new Set()).reason).toBe("policy-disabled"); - }); - - it("enforces the bounded JSON event envelope", () => { - expect(validateExternalEvent(fixture)).toEqual(fixture); - expect(() => validateExternalEvent({ ...fixture, payload: { body: "x".repeat(9_000) } })).toThrow("8 KiB"); - expect(() => validateExternalEvent({ ...fixture, command: "rm -rf /" })).toThrow("unsupported field"); - }); -}); diff --git a/packages/mtmharness/src/features/connect/contract/event.ts b/packages/mtmharness/src/features/connect/contract/event.ts deleted file mode 100644 index a219aa5..0000000 --- a/packages/mtmharness/src/features/connect/contract/event.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { isJsonValue, isRecord, jsonByteLength, type JsonObject, type JsonValue } from "./json.ts"; - -export const EVENT_POLICIES = ["observe", "inject-next", "wake-agent", "require-approval", "disabled"] as const; -export type EventPolicy = (typeof EVENT_POLICIES)[number]; -export type EventDisposition = "observed" | "queued" | "wake-agent" | "approval-required" | "dropped"; - -export interface ExternalConnectionEvent { - readonly eventId: string; - readonly connectionId: string; - readonly capabilityId: string; - readonly generation: number; - readonly occurredAt: number; - readonly kind: string; - readonly payload: JsonObject; - readonly dedupeKey: string; - readonly source: string; -} - -export interface EventProjection { - readonly eventId: string; - readonly dedupeKey: string; - readonly policy: EventPolicy; - readonly disposition: EventDisposition; - readonly reason?: string; -} - -export interface EventRecord { - readonly event: ExternalConnectionEvent; - readonly projection: EventProjection; - readonly recordedAt: number; -} - -const MAX_EVENT_BYTES = 8_192; -const ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/; - -function exactKeys(value: Record, allowed: readonly string[], label: string): void { - const allowedSet = new Set(allowed); - for (const key of Object.keys(value)) { - if (!allowedSet.has(key)) throw new Error(label + " contains unsupported field: " + key); - } -} - -function stringValue(value: unknown, label: string): string { - if (typeof value !== "string" || !ID_PATTERN.test(value)) throw new Error(label + " must be a bounded identifier"); - return value; -} - -function integerValue(value: unknown, label: string): number { - if (!Number.isInteger(value) || (value as number) < 0) throw new Error(label + " must be a non-negative integer"); - return value as number; -} - -export function validateExternalEvent(value: unknown): ExternalConnectionEvent { - if (!isRecord(value)) throw new Error("external event must be an object"); - exactKeys(value, ["eventId", "connectionId", "capabilityId", "generation", "occurredAt", "kind", "payload", "dedupeKey", "source"], "external event"); - if (!isRecord(value.payload)) throw new Error("external event payload must be an object"); - if (!Object.values(value.payload).every(isJsonValue)) throw new Error("external event payload must be JSON-safe"); - const event: ExternalConnectionEvent = { - eventId: stringValue(value.eventId, "eventId"), - connectionId: stringValue(value.connectionId, "connectionId"), - capabilityId: stringValue(value.capabilityId, "capabilityId"), - generation: integerValue(value.generation, "generation"), - occurredAt: integerValue(value.occurredAt, "occurredAt"), - kind: stringValue(value.kind, "event kind"), - payload: value.payload as JsonObject, - dedupeKey: stringValue(value.dedupeKey, "dedupeKey"), - source: stringValue(value.source, "event source"), - }; - if (jsonByteLength(event as unknown as JsonValue) > MAX_EVENT_BYTES) throw new Error("external event exceeds the 8 KiB payload limit"); - return event; -} - -function dropped(event: ExternalConnectionEvent, policy: EventPolicy, reason: string): EventProjection { - return { eventId: event.eventId, dedupeKey: event.dedupeKey, policy, disposition: "dropped", reason }; -} - -export function projectExternalEvent( - event: ExternalConnectionEvent, - policy: EventPolicy, - seenDedupeKeys: ReadonlySet, -): EventProjection { - if (seenDedupeKeys.has(event.dedupeKey)) return dropped(event, policy, "duplicate-dedupe-key"); - if (policy === "disabled") return dropped(event, policy, "policy-disabled"); - if (policy === "observe") return { eventId: event.eventId, dedupeKey: event.dedupeKey, policy, disposition: "observed" }; - if (policy === "inject-next") return { eventId: event.eventId, dedupeKey: event.dedupeKey, policy, disposition: "queued" }; - if (policy === "wake-agent") return { eventId: event.eventId, dedupeKey: event.dedupeKey, policy, disposition: "wake-agent" }; - return { eventId: event.eventId, dedupeKey: event.dedupeKey, policy, disposition: "approval-required" }; -} diff --git a/packages/mtmharness/src/features/connect/contract/json.ts b/packages/mtmharness/src/features/connect/contract/json.ts deleted file mode 100644 index 38e5ab8..0000000 --- a/packages/mtmharness/src/features/connect/contract/json.ts +++ /dev/null @@ -1,41 +0,0 @@ -export type JsonPrimitive = string | number | boolean | null; -export type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: JsonValue }; -export type JsonObject = { readonly [key: string]: JsonValue }; - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -export function isJsonValue(value: unknown): value is JsonValue { - if (value === null || typeof value === "string" || typeof value === "boolean") return true; - if (typeof value === "number") return Number.isFinite(value); - if (Array.isArray(value)) return value.every(isJsonValue); - if (!isRecord(value)) return false; - return Object.values(value).every(isJsonValue); -} - -export function cloneJson(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -export function jsonByteLength(value: JsonValue): number { - return new TextEncoder().encode(JSON.stringify(value)).byteLength; -} - -const SECRET_KEY_PATTERN = /(?:password|secret|token|private[_-]?key|client[_-]?secret)/i; - -export function assertPublicConfig(config: JsonObject): void { - const visit = (value: JsonValue, path: string): void => { - if (Array.isArray(value)) { - value.forEach((item, index) => { visit(item, path + "[" + index + "]"); }); - return; - } - if (!isRecord(value)) return; - for (const [key, child] of Object.entries(value)) { - if (SECRET_KEY_PATTERN.test(key)) throw new Error("connection config cannot contain credential field: " + path + key); - visit(child as JsonValue, path + key + "."); - } - }; - if (!isJsonValue(config)) throw new Error("connection config must contain JSON-safe values"); - visit(config, ""); -} diff --git a/packages/mtmharness/src/features/connect/contract/rpc.ts b/packages/mtmharness/src/features/connect/contract/rpc.ts deleted file mode 100644 index 6a9c529..0000000 --- a/packages/mtmharness/src/features/connect/contract/rpc.ts +++ /dev/null @@ -1,191 +0,0 @@ -import type { CapabilityBinding, BindingScope, MtmConnectMutation, MtmConnectSnapshot, MtmConnectInvocationRequest } from "./connection.ts"; -import { EVENT_POLICIES, validateExternalEvent, type EventPolicy, type ExternalConnectionEvent } from "./event.ts"; -import { isJsonValue, isRecord, type JsonObject } from "./json.ts"; -import type { CapabilityInvocationResult } from "./connection.ts"; -import { validateMtmControlSnapshot, type MtmControlSnapshot } from "./control-plane.ts"; - -export const MTM_CONNECT_CHANNEL = "/mtm-connect"; - -export type MtmConnectRpcRequest = - | { readonly kind: "snapshot" } - | { readonly kind: "mutate"; readonly mutation: MtmConnectMutation } - | { readonly kind: "invoke"; readonly request: MtmConnectInvocationRequest } - | { readonly kind: "reconcile"; readonly snapshot: MtmControlSnapshot }; - -export interface MtmConnectMutationResponse { - readonly snapshot: MtmConnectSnapshot; - readonly projection?: import("./event.ts").EventProjection; -} - -const ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; -const PROJECTION_DISPOSITIONS = ["observed", "queued", "wake-agent", "approval-required", "dropped"] as const; - -function exactKeys(value: Record, allowed: readonly string[], label: string): void { - const allowedSet = new Set(allowed); - for (const key of Object.keys(value)) { - if (!allowedSet.has(key)) throw new Error(label + " contains unsupported field: " + key); - } -} - -function stringValue(value: unknown, label: string): string { - if (typeof value !== "string" || !ID_PATTERN.test(value)) throw new Error(label + " must be a bounded identifier"); - return value; -} - -function labelValue(value: unknown): string { - if (typeof value !== "string" || value.trim().length < 2 || value.trim().length > 80) throw new Error("connection label is invalid"); - return value.trim(); -} - -function objectValue(value: unknown, label: string): JsonObject { - if (!isRecord(value) || !Object.values(value).every(isJsonValue)) throw new Error(label + " must be a JSON object"); - return value as JsonObject; -} - -function scopeValue(value: unknown): BindingScope { - if (value !== "profile" && value !== "sandbox" && value !== "session") throw new Error("connection scope is invalid"); - return value; -} - -function policyValue(value: unknown): EventPolicy { - if (!EVENT_POLICIES.includes(value as EventPolicy)) throw new Error("event policy is invalid"); - return value as EventPolicy; -} - -function booleanValue(value: unknown, label: string): boolean { - if (typeof value !== "boolean") throw new Error(label + " must be a boolean"); - return value; -} - -function integerValue(value: unknown, label: string): number { - if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(label + " must be a non-negative safe integer"); - return value as number; -} - -function parsePolicyPatch(value: unknown): Partial> { - if (!isRecord(value)) throw new Error("capability policy patch must be an object"); - exactKeys(value, ["enabled", "modelInvocable", "userInvocable", "eventPolicy"], "capability policy patch"); - if (Object.keys(value).length === 0) throw new Error("capability policy patch cannot be empty"); - return { - ...(value.enabled === undefined ? {} : { enabled: booleanValue(value.enabled, "enabled") }), - ...(value.modelInvocable === undefined ? {} : { modelInvocable: booleanValue(value.modelInvocable, "modelInvocable") }), - ...(value.userInvocable === undefined ? {} : { userInvocable: booleanValue(value.userInvocable, "userInvocable") }), - ...(value.eventPolicy === undefined ? {} : { eventPolicy: policyValue(value.eventPolicy) }), - }; -} - -function parseMutation(value: unknown): MtmConnectMutation { - if (!isRecord(value) || typeof value.type !== "string") throw new Error("mtm-connect mutation must be an object with a type"); - switch (value.type) { - case "create": - exactKeys(value, ["type", "adapterId", "label", "config", "scope"], "create mutation"); - return { - type: "create", - adapterId: stringValue(value.adapterId, "adapterId"), - label: labelValue(value.label), - config: objectValue(value.config, "config"), - ...(value.scope === undefined ? {} : { scope: scopeValue(value.scope) }), - }; - case "enable": - exactKeys(value, ["type", "connectionId"], "enable mutation"); - return { type: "enable", connectionId: stringValue(value.connectionId, "connectionId") }; - case "disable": - exactKeys(value, ["type", "connectionId"], "disable mutation"); - return { type: "disable", connectionId: stringValue(value.connectionId, "connectionId") }; - case "revoke": - exactKeys(value, ["type", "connectionId"], "revoke mutation"); - return { type: "revoke", connectionId: stringValue(value.connectionId, "connectionId") }; - case "reconnect": - exactKeys(value, ["type", "connectionId"], "reconnect mutation"); - return { type: "reconnect", connectionId: stringValue(value.connectionId, "connectionId") }; - case "set-policy": - exactKeys(value, ["type", "connectionId", "capabilityId", "patch"], "policy mutation"); - return { - type: "set-policy", - connectionId: stringValue(value.connectionId, "connectionId"), - capabilityId: stringValue(value.capabilityId, "capabilityId"), - patch: parsePolicyPatch(value.patch), - }; - case "event": - exactKeys(value, ["type", "connectionId", "event"], "event mutation"); - if (!isRecord(value.event)) throw new Error("event mutation event is required"); - return { - type: "event", - connectionId: stringValue(value.connectionId, "connectionId"), - event: validateExternalEvent(value.event), - }; - default: - throw new Error("unsupported mtm-connect mutation"); - } -} - -function parseInvocation(value: unknown): MtmConnectInvocationRequest { - if (!isRecord(value)) throw new Error("invocation request must be an object"); - exactKeys(value, ["connectionId", "generation", "capabilityId", "operationId", "input", "actor", "approved"], "invocation request"); - if (value.actor !== "user" && value.actor !== "model") throw new Error("invocation actor is invalid"); - return { - connectionId: stringValue(value.connectionId, "connectionId"), - generation: integerValue(value.generation, "generation"), - capabilityId: stringValue(value.capabilityId, "capabilityId"), - operationId: stringValue(value.operationId, "operationId"), - input: objectValue(value.input, "input"), - actor: value.actor, - ...(value.approved === undefined ? {} : { approved: booleanValue(value.approved, "approved") }), - }; -} - -export function parseMtmConnectRpcRequest(value: unknown): MtmConnectRpcRequest { - if (!isRecord(value) || typeof value.kind !== "string") throw new Error("mtm-connect RPC request is invalid"); - switch (value.kind) { - case "snapshot": - exactKeys(value, ["kind"], "snapshot request"); - return { kind: "snapshot" }; - case "mutate": - exactKeys(value, ["kind", "mutation"], "mutation request"); - return { kind: "mutate", mutation: parseMutation(value.mutation) }; - case "invoke": - exactKeys(value, ["kind", "request"], "invoke request"); - return { kind: "invoke", request: parseInvocation(value.request) }; - case "reconcile": - exactKeys(value, ["kind", "snapshot"], "reconcile request"); - validateMtmControlSnapshot(value.snapshot); - return { kind: "reconcile", snapshot: value.snapshot }; - default: - throw new Error("unsupported mtm-connect RPC request"); - } -} - -export function assertMtmConnectSnapshot(value: unknown): asserts value is MtmConnectSnapshot { - if (!isRecord(value) || value.schemaVersion !== 2) throw new Error("mtm-connect RPC returned an invalid snapshot"); -} - -export function assertMtmConnectMutationResponse(value: unknown): asserts value is MtmConnectMutationResponse { - if (!isRecord(value) || !isRecord(value.snapshot)) throw new Error("mtm-connect RPC returned an invalid mutation response"); - if (value.projection !== undefined) { - if (!isRecord(value.projection) - || typeof value.projection.eventId !== "string" - || typeof value.projection.dedupeKey !== "string" - || !EVENT_POLICIES.includes(value.projection.policy as EventPolicy) - || !PROJECTION_DISPOSITIONS.includes(value.projection.disposition as (typeof PROJECTION_DISPOSITIONS)[number])) { - - throw new Error("mtm-connect RPC returned an invalid event projection"); - } - } -} - -export function assertMtmConnectInvocationResult(value: unknown): asserts value is CapabilityInvocationResult { - if (!isRecord(value) || typeof value.ok !== "boolean") throw new Error("mtm-connect RPC returned an invalid invocation result"); - if (value.ok) { - if (typeof value.simulated !== "boolean" || typeof value.adapterId !== "string" || typeof value.connectionId !== "string" - || !Number.isSafeInteger(value.generation) || typeof value.capabilityId !== "string" || typeof value.operationId !== "string" - || typeof value.summary !== "string" || !isRecord(value.data) || !Object.values(value.data).every(isJsonValue)) { - throw new Error("mtm-connect RPC returned an invalid successful invocation"); - } - return; - } - const codes = ["connection-not-found", "connection-offline", "stale-generation", "capability-not-found", "capability-disabled", "policy-denied", "approval-required", "input-too-large", "output-too-large", "adapter-unavailable", "unsupported-operation", "invalid-input"] as const; - if (!codes.includes(value.code as (typeof codes)[number]) || typeof value.message !== "string") throw new Error("mtm-connect RPC returned an invalid invocation failure"); -} - -// Keep these imports in the shared module's type surface without making the wire parser depend on runtime services. -export type { MtmConnectMutation, MtmConnectSnapshot, MtmConnectInvocationRequest, ExternalConnectionEvent }; diff --git a/packages/mtmharness/src/features/connect/contract/snapshot.ts b/packages/mtmharness/src/features/connect/contract/snapshot.ts deleted file mode 100644 index 5e623a1..0000000 --- a/packages/mtmharness/src/features/connect/contract/snapshot.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { validateAdapterDescriptor, type AdapterDescriptor } from "./adapter.ts"; -import { validateMtmModelProfileRef, type MtmModelProfileRef } from "./control-plane.ts"; -import type { - BindingScope, - CapabilityBinding, - ConnectionRecord, - ConnectionObservation, - ConnectionInstance, - MtmConnectSnapshot, - WorldBinding, -} from "./connection.ts"; -import { assertPublicConfig, isRecord, isJsonValue, type JsonObject } from "./json.ts"; -import { EVENT_POLICIES, validateExternalEvent, type EventDisposition, type EventPolicy, type EventProjection, type EventRecord } from "./event.ts"; - -const DESIRED_STATES = ["disabled", "enabled"] as const; -const OBSERVED_STATES = ["configured", "authorizing", "enrolled", "connecting", "online", "degraded", "offline", "revoked"] as const; -const BINDING_SCOPES = ["profile", "sandbox", "session"] as const; -const PROJECTION_DISPOSITIONS = ["observed", "queued", "wake-agent", "approval-required", "dropped"] as const; -const ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; - -function exactKeys(value: Record, allowed: readonly string[], label: string): void { - const allowedSet = new Set(allowed); - for (const key of Object.keys(value)) { - if (!allowedSet.has(key)) throw new Error(label + " contains unsupported field: " + key); - } -} - -function stringValue(value: unknown, label: string, pattern = /.+/): string { - if (typeof value !== "string" || value.length === 0 || value.length > 256 || !pattern.test(value)) throw new Error(label + " is invalid"); - return value; -} - -function integerValue(value: unknown, label: string): number { - if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(label + " must be a non-negative safe integer"); - return value as number; -} - -function oneOf(value: unknown, values: readonly T[], label: string): T { - if (!values.includes(value as T)) throw new Error(label + " is invalid"); - return value as T; -} - -function validateBinding(value: unknown, key: string, capabilityIds: ReadonlySet): CapabilityBinding { - if (!isRecord(value)) throw new Error("binding " + key + " must be an object"); - exactKeys(value, ["capabilityId", "enabled", "modelInvocable", "userInvocable", "eventPolicy"], "binding " + key); - if (value.capabilityId !== key || !capabilityIds.has(key)) throw new Error("binding capability does not match adapter descriptor"); - if (typeof value.enabled !== "boolean" || typeof value.modelInvocable !== "boolean" || typeof value.userInvocable !== "boolean") throw new Error("binding policy flags are invalid"); - return { - capabilityId: key, - enabled: value.enabled, - modelInvocable: value.modelInvocable, - userInvocable: value.userInvocable, - eventPolicy: oneOf(value.eventPolicy, EVENT_POLICIES, "binding eventPolicy"), - }; -} - -function validateWorldBinding(value: unknown, capabilityIds: ReadonlySet, primaryCapabilityId: string | undefined): WorldBinding { - if (!isRecord(value)) throw new Error("worldBinding must be an object"); - exactKeys(value, ["capabilityId", "scope", "status"], "worldBinding"); - if (value.capabilityId !== primaryCapabilityId || !capabilityIds.has(String(value.capabilityId))) throw new Error("worldBinding must select the adapter primary-world capability"); - return { - capabilityId: String(value.capabilityId), - scope: oneOf(value.scope, BINDING_SCOPES, "worldBinding scope") as BindingScope, - status: oneOf(value.status, ["selected", "not-selected"] as const, "worldBinding status"), - }; -} - -function validateObservation(value: unknown, instance: ConnectionInstance): ConnectionObservation { - if (!isRecord(value)) throw new Error("connection observation must be an object"); - exactKeys(value, ["status", "generation", "channelId", "lastSeenAt", "expiresAt", "lastError"], "connection observation"); - const status = oneOf(value.status, OBSERVED_STATES, "connection observation status"); - const generation = integerValue(value.generation, "connection generation"); - const channelId = value.channelId === undefined ? undefined : stringValue(value.channelId, "connection channelId", ID_PATTERN); - const lastSeenAt = value.lastSeenAt === undefined ? undefined : integerValue(value.lastSeenAt, "connection lastSeenAt"); - const expiresAt = value.expiresAt === undefined ? undefined : integerValue(value.expiresAt, "connection expiresAt"); - if (status === "online" && (instance.desired !== "enabled" || channelId === undefined || lastSeenAt === undefined)) throw new Error("online connection must have enabled desired state and channel facts"); - if (status !== "online" && channelId !== undefined) throw new Error("offline connection cannot retain a channelId"); - if (status === "revoked" && instance.desired !== "disabled") throw new Error("revoked connection must be disabled"); - let lastError: ConnectionObservation["lastError"]; - if (value.lastError !== undefined) { - if (!isRecord(value.lastError)) throw new Error("connection lastError must be an object"); - exactKeys(value.lastError, ["code", "message"], "connection lastError"); - lastError = { code: stringValue(value.lastError.code, "connection error code", ID_PATTERN), message: stringValue(value.lastError.message, "connection error message") }; - } - return { status, generation, ...(channelId === undefined ? {} : { channelId }), ...(lastSeenAt === undefined ? {} : { lastSeenAt }), ...(expiresAt === undefined ? {} : { expiresAt }), ...(lastError === undefined ? {} : { lastError }) }; -} - -function validateConnection(value: unknown, index: number, ownerId: string, adapters: readonly AdapterDescriptor[]): ConnectionRecord { - if (!isRecord(value) || !isRecord(value.instance) || !isRecord(value.observation)) throw new Error("connection[" + index + "] must be a record"); - exactKeys(value, ["instance", "observation"], "connection[" + index + "]"); - const raw = value.instance; - exactKeys(raw, ["id", "ownerId", "adapterId", "label", "config", "desired", "bindings", "worldBinding", "fixture", "createdAt", "updatedAt"], "connection instance"); - const id = stringValue(raw.id, "connection id", ID_PATTERN); - if (raw.ownerId !== ownerId) throw new Error("connection owner does not match snapshot owner"); - const adapterId = stringValue(raw.adapterId, "connection adapterId", ID_PATTERN); - const adapter = adapters.find((candidate) => candidate.id === adapterId); - if (adapter === undefined) throw new Error("connection references unknown adapter: " + adapterId); - if (adapter.status !== "installed" && raw.desired !== "disabled") throw new Error("active connection references unavailable adapter"); - if (!isRecord(raw.config) || !Object.values(raw.config).every(isJsonValue)) throw new Error("connection config must be JSON-safe"); - const config = raw.config as JsonObject; - assertPublicConfig(config); - const desired = oneOf(raw.desired, DESIRED_STATES, "connection desired state"); - const label = stringValue(raw.label, "connection label"); - if (label.trim().length < 2 || label.trim().length > 80) throw new Error("connection label is invalid"); - if (typeof raw.fixture !== "boolean") throw new Error("connection fixture flag is invalid"); - const createdAt = integerValue(raw.createdAt, "connection createdAt"); - const updatedAt = integerValue(raw.updatedAt, "connection updatedAt"); - if (updatedAt < createdAt) throw new Error("connection updatedAt precedes createdAt"); - if (!isRecord(raw.bindings)) throw new Error("connection bindings must be an object"); - const capabilityIds = new Set(adapter.capabilities.map((capability) => capability.id)); - const bindingKeys = Object.keys(raw.bindings); - if (bindingKeys.length !== capabilityIds.size || bindingKeys.some((key) => !capabilityIds.has(key))) throw new Error("connection bindings do not match adapter capabilities"); - const bindings: Record = {}; - for (const key of bindingKeys) bindings[key] = validateBinding(raw.bindings[key], key, capabilityIds); - const primary = adapter.capabilities.find((capability) => capability.role === "primary-world")?.id; - let worldBinding: WorldBinding | undefined; - if (raw.worldBinding !== undefined) worldBinding = validateWorldBinding(raw.worldBinding, capabilityIds, primary); - else if (primary !== undefined) throw new Error("primary-world connection must carry a worldBinding"); - const instance: ConnectionInstance = { - id, - ownerId, - adapterId, - label, - config, - desired, - bindings, - ...(worldBinding === undefined ? {} : { worldBinding }), - fixture: raw.fixture, - createdAt, - updatedAt, - }; - return { instance, observation: validateObservation(value.observation, instance) }; -} - -function validateProjection(value: unknown, event: ReturnType): EventProjection { - if (!isRecord(value)) throw new Error("event projection must be an object"); - exactKeys(value, ["eventId", "dedupeKey", "policy", "disposition", "reason"], "event projection"); - if (value.eventId !== event.eventId || value.dedupeKey !== event.dedupeKey) throw new Error("event projection identity does not match event"); - const projection: EventProjection = { - eventId: event.eventId, - dedupeKey: event.dedupeKey, - policy: oneOf(value.policy, EVENT_POLICIES, "event projection policy") as EventPolicy, - disposition: oneOf(value.disposition, PROJECTION_DISPOSITIONS, "event projection disposition") as EventDisposition, - ...(value.reason === undefined ? {} : { reason: stringValue(value.reason, "event projection reason") }), - }; - return projection; -} - -function validateEventRecord(value: unknown, index: number, connections: readonly ConnectionRecord[]): EventRecord { - if (!isRecord(value)) throw new Error("eventHistory[" + index + "] must be an object"); - exactKeys(value, ["event", "projection", "recordedAt"], "eventHistory[" + index + "]"); - const event = validateExternalEvent(value.event); - const connection = connections.find((record) => record.instance.id === event.connectionId); - if (connection === undefined) throw new Error("event references unknown connection"); - if (connection.instance.bindings[event.capabilityId] === undefined) throw new Error("event references unknown capability"); - return { event, projection: validateProjection(value.projection, event), recordedAt: integerValue(value.recordedAt, "event recordedAt") }; -} - -export function cloneSnapshot(snapshot: MtmConnectSnapshot): MtmConnectSnapshot { - return JSON.parse(JSON.stringify(snapshot)) as MtmConnectSnapshot; -} - -export function validateSnapshot(value: unknown): MtmConnectSnapshot { - if (!isRecord(value)) throw new Error("mtm-connect snapshot must be an object"); - exactKeys(value, ["schemaVersion", "revision", "controlRevision", "ownerId", "adapters", "connections", "activeModelProfile", "eventHistory", "updatedAt"], "mtm-connect snapshot"); - if (value.schemaVersion !== 2) throw new Error("unsupported mtm-connect snapshot version"); - const revision = integerValue(value.revision, "snapshot revision"); - const controlRevision = value.controlRevision === undefined ? undefined : integerValue(value.controlRevision, "snapshot control revision"); - const activeModelProfile = value.activeModelProfile === null - ? null - : (validateMtmModelProfileRef(value.activeModelProfile), value.activeModelProfile as MtmModelProfileRef); - const ownerId = stringValue(value.ownerId, "snapshot ownerId", ID_PATTERN); - if (!Array.isArray(value.adapters) || !Array.isArray(value.connections) || !Array.isArray(value.eventHistory)) throw new Error("snapshot collections are invalid"); - const adapters: AdapterDescriptor[] = value.adapters.map(validateAdapterDescriptor); - const adapterIds = new Set(); - for (const adapter of adapters) { - if (adapterIds.has(adapter.id)) throw new Error("duplicate adapter id: " + adapter.id); - adapterIds.add(adapter.id); - } - const connections = value.connections.map((record, index) => validateConnection(record, index, ownerId, adapters)); - const connectionIds = new Set(); - for (const connection of connections) { - if (connectionIds.has(connection.instance.id)) throw new Error("duplicate connection id: " + connection.instance.id); - connectionIds.add(connection.instance.id); - } - const eventHistory = value.eventHistory.map((event, index) => validateEventRecord(event, index, connections)); - return { schemaVersion: 2, revision, ...(controlRevision === undefined ? {} : { controlRevision }), ownerId, adapters, connections, activeModelProfile, eventHistory, updatedAt: integerValue(value.updatedAt, "snapshot updatedAt") }; -} - -export function asJsonSnapshot(snapshot: MtmConnectSnapshot): import("./json.ts").JsonValue { - const validated = validateSnapshot(snapshot); - return validated as unknown as import("./json.ts").JsonValue; -} diff --git a/packages/mtmharness/src/features/connect/core/control-projection.test.ts b/packages/mtmharness/src/features/connect/core/control-projection.test.ts deleted file mode 100644 index b73ce5d..0000000 --- a/packages/mtmharness/src/features/connect/core/control-projection.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { CapabilityInvoker } from "../adapters/invoker.ts"; -import { createAdapterCatalog } from "../adapters/catalog.ts"; -import type { AdapterDescriptor } from "../contract/adapter.ts"; -import type { MtmControlSnapshot } from "../contract/control-plane.ts"; -import { validateMtmControlSnapshot } from "../contract/control-plane.ts"; -import { MtmConnectRegistry } from "./registry.ts"; - -const scope = { - sandboxId: "sbx_00000000-0000-4000-8000-000000000607", - workspaceId: "ws_00000000-0000-4000-8000-000000000607", - owner: { issuer: "https://auth.example.test", subject: "user-1" }, -}; - -function controlSnapshot(overrides: Partial = {}): MtmControlSnapshot { - const adapter = createAdapterCatalog().find((candidate) => candidate.id === "mock-world") as AdapterDescriptor; - const controlAdapter = { - adapterId: adapter.id, - version: adapter.version, - label: adapter.label, - available: adapter.status === "installed", - capabilities: adapter.capabilities.map((capability) => ({ - capabilityId: capability.id, - version: capability.version, - role: capability.role, - operations: capability.operations.map((operation) => ({ - operationId: operation.id, - sideEffect: operation.sideEffect, - requiresApproval: operation.requiresApproval, - })), - })), - }; - const capabilities = Object.fromEntries(adapter.capabilities.map((capability) => [capability.id, { - capabilityId: capability.id, - enabled: true, - modelInvocable: capability.role !== "primary-world", - userInvocable: true, - eventPolicy: "observe" as const, - }])); - return { - contractVersion: 2, - scope, - revision: 1, - adapters: [controlAdapter], - desiredWorlds: [{ worldId: "world-1", adapterId: adapter.id, config: { root: "/workspace" }, enabled: true, capabilities }], - observedWorlds: [{ worldId: "world-1", adapterId: adapter.id, status: "online", generation: 4, channelId: "world-1:channel:4", lastSeenAt: 42 }], - installation: { installationId: "installation-1", daemonId: "daemon-1", generation: 4, status: "active", boundAt: 1, heartbeatAt: 42, expiresAt: 9_000_000_000_000 }, - activeModelProfile: { tenantId: "tenant-1", profileId: "yuepa8-default", revision: 3 }, - ...overrides, - }; -} - -describe("MtmConnectRegistry control projection", () => { - it("projects desired, observed, generation, and policy into the local registry", () => { - const registry = new MtmConnectRegistry({ ownerId: "user-1", seed: false, scope }); - registry.reconcileControlSnapshot(controlSnapshot()); - const connection = registry.getConnection("world-1"); - expect(registry.getControlRevision()).toBe(1); - expect(registry.getSnapshot().activeModelProfile).toEqual({ tenantId: "tenant-1", profileId: "yuepa8-default", revision: 3 }); - expect(connection).toMatchObject({ - instance: { desired: "enabled", adapterId: "mock-world" }, - observation: { status: "online", generation: 4, channelId: "world-1:channel:4" }, - }); - const primary = connection?.instance.worldBinding?.capabilityId; - expect(connection?.instance.bindings[primary ?? ""]?.modelInvocable).toBe(false); - - const restored = new MtmConnectRegistry({ ownerId: "user-1", seed: false, scope, snapshot: registry.getSnapshot() }); - expect(restored.getControlRevision()).toBe(1); - expect(restored.getSnapshot().activeModelProfile).toEqual({ tenantId: "tenant-1", profileId: "yuepa8-default", revision: 3 }); - expect(restored.reconcileControlSnapshot({ ...controlSnapshot(), revision: 0 })).toEqual(restored.getSnapshot()); - }); - - it("passes the authoritative profile reference to the injected invoker", async () => { - const calls: Array[0]> = []; - const invoker: CapabilityInvoker = async (context) => { - calls.push(context); - return { ok: true, simulated: false, summary: "profile-aware read", data: {} }; - }; - const registry = new MtmConnectRegistry({ ownerId: "user-1", seed: false, scope, capabilityInvoker: invoker }); - registry.reconcileControlSnapshot(controlSnapshot()); - - const result = await registry.invokeCapability("world-1", 4, "workspace.execution", "workspace.list", {}, "user"); - expect(result).toMatchObject({ ok: true, summary: "profile-aware read" }); - expect(calls[0]?.modelProfile).toEqual({ tenantId: "tenant-1", profileId: "yuepa8-default", revision: 3 }); - }); - - it("ignores out-of-order control revisions and rejects foreign scope or generation", async () => { - const registry = new MtmConnectRegistry({ ownerId: "user-1", seed: false, scope }); - registry.reconcileControlSnapshot(controlSnapshot()); - const before = registry.getSnapshot(); - registry.reconcileControlSnapshot({ ...controlSnapshot(), revision: 0 }); - expect(registry.getSnapshot()).toEqual(before); - - expect(() => registry.reconcileControlSnapshot({ ...controlSnapshot(), revision: 2, scope: { ...scope, owner: { ...scope.owner, subject: "user-2" } } })).toThrow("owner"); - expect(() => registry.reconcileControlSnapshot({ ...controlSnapshot(), revision: 2, observedWorlds: [{ ...controlSnapshot().observedWorlds[0]!, generation: 3 }], installation: { ...controlSnapshot().installation!, generation: 3 } })).toThrow("generation"); - expect(() => registry.reconcileControlSnapshot({ ...controlSnapshot(), revision: 2, adapters: [{ ...controlSnapshot().adapters[0]!, version: "9.9.9" }] })).toThrow("descriptor"); - expect(() => registry.reconcileControlSnapshot({ ...controlSnapshot(), revision: 2, desiredWorlds: [{ ...controlSnapshot().desiredWorlds[0]!, config: { value: "Bearer opaque-value" } }] })).toThrow("secret"); - - const expiredRegistry = new MtmConnectRegistry({ ownerId: "user-1", seed: false, scope, now: () => 100 }); - const expired = controlSnapshot({ - installation: { ...controlSnapshot().installation!, heartbeatAt: 42, expiresAt: 42 }, - }); - expiredRegistry.reconcileControlSnapshot(expired); - expect(expiredRegistry.getConnection("world-1")?.observation.status).toBe("offline"); - - let clock = 50; - const expiringRegistry = new MtmConnectRegistry({ ownerId: "user-1", seed: false, scope, now: () => clock }); - expiringRegistry.reconcileControlSnapshot(controlSnapshot({ installation: { ...controlSnapshot().installation!, expiresAt: 100 } })); - const primaryCapability = expiringRegistry.getConnection("world-1")?.instance.worldBinding?.capabilityId ?? ""; - const operationId = expiringRegistry.getAdapter("mock-world")?.capabilities.find((capability) => capability.id === primaryCapability)?.operations[0]?.id ?? ""; - clock = 101; - expect(await expiringRegistry.invokeCapability("world-1", 4, primaryCapability, operationId, { path: "/workspace" }, "user")).toMatchObject({ ok: false, code: "connection-offline" }); - }); - - it("fails closed for revoked installations and secret-bearing snapshots", () => { - const registry = new MtmConnectRegistry({ ownerId: "user-1", seed: false, scope }); - const observed = controlSnapshot().observedWorlds[0]!; - const revokedObserved = { worldId: observed.worldId, adapterId: observed.adapterId, generation: observed.generation }; - const revoked = controlSnapshot({ - revision: 1, - installation: { ...controlSnapshot().installation!, status: "revoked", revokedAt: 50 }, - desiredWorlds: [{ ...controlSnapshot().desiredWorlds[0]!, enabled: false }], - observedWorlds: [{ ...revokedObserved, status: "revoked" }], - }); - registry.reconcileControlSnapshot(revoked); - expect(registry.getConnection("world-1")?.observation.status).toBe("revoked"); - - const secret = controlSnapshot({ desiredWorlds: [{ ...controlSnapshot().desiredWorlds[0]!, config: { refresh_token: "no" } }] }); - expect(() => validateMtmControlSnapshot(secret)).toThrow("secret"); - expect(() => validateMtmControlSnapshot(controlSnapshot({ activeModelProfile: { tenantId: "tenant-1", profileId: "yuepa8-default", revision: 0 } }))).toThrow("revision"); - expect(() => validateMtmControlSnapshot(controlSnapshot({ activeModelProfile: { tenantId: "tenant-1", profileId: "yuepa8-default", revision: 3, credentialRef: "managed" } as never }))).toThrow("unsupported field"); - const missingProfile = { ...controlSnapshot() } as Record; - delete missingProfile.activeModelProfile; - expect(() => validateMtmControlSnapshot(missingProfile)).toThrow("mtm model profile"); - const mismatchedObserved = controlSnapshot({ observedWorlds: [{ ...controlSnapshot().observedWorlds[0]!, adapterId: "mock-device" }] }); - expect(() => validateMtmControlSnapshot(mismatchedObserved)).toThrow("observed world"); - }); -}); diff --git a/packages/mtmharness/src/features/connect/core/provider-invoker.test.ts b/packages/mtmharness/src/features/connect/core/provider-invoker.test.ts deleted file mode 100644 index 965d71a..0000000 --- a/packages/mtmharness/src/features/connect/core/provider-invoker.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { CapabilityInvoker } from "../adapters/invoker.ts"; -import { createMtmConnectRpcHandler } from "../index.ts"; -import { MtmConnectRegistry } from "./registry.ts"; - -describe("mtm-connect capability invoker boundary", () => { - it("routes validated provider context after policy checks", async () => { - const calls: Array[0]> = []; - const providerInvoker: CapabilityInvoker = async (context) => { - calls.push(context); - await Promise.resolve(); - return { - ok: true, - simulated: false, - summary: "Provider read completed", - data: { source: "provider", path: context.input.path ?? null }, - }; - }; - const registry = new MtmConnectRegistry({ ownerId: "user-1", seed: true, capabilityInvoker: providerInvoker }); - - registry.enable("mock-workstation"); - const generation = registry.getConnection("mock-workstation")?.observation.generation ?? 0; - const result = await registry.invokeCapability( - "mock-workstation", - generation, - "workspace.execution", - "workspace.list", - { path: "/workspace/provider" }, - "user", - ); - - expect(result).toMatchObject({ - ok: true, - simulated: false, - adapterId: "mock-world", - connectionId: "mock-workstation", - capabilityId: "workspace.execution", - operationId: "workspace.list", - data: { source: "provider", path: "/workspace/provider" }, - }); - expect(calls).toHaveLength(1); - expect(calls[0]).toMatchObject({ - adapter: { id: "mock-world", version: "0.1.0" }, - capability: { id: "workspace.execution", role: "primary-world" }, - operation: { id: "workspace.list", sideEffect: "read", requiresApproval: false }, - connection: { id: "mock-workstation", adapterId: "mock-world", config: { root: "/workspace/demo" } }, - input: { path: "/workspace/provider" }, - }); - - registry.enable("mock-android"); - const deviceGeneration = registry.getConnection("mock-android")?.observation.generation ?? 0; - const deniedWrite = await registry.invokeCapability( - "mock-android", - deviceGeneration, - "device.control", - "input.tap", - { x: 10, y: 20 }, - "user", - ); - - expect(deniedWrite).toMatchObject({ ok: false, code: "approval-required" }); - expect(calls).toHaveLength(1); - }); - - it("routes async execution through the Host RPC contract", async () => { - const registry = new MtmConnectRegistry({ - ownerId: "user-1", - seed: true, - capabilityInvoker: async () => ({ ok: true, simulated: false, summary: "RPC provider read", data: { source: "rpc-provider" } }), - }); - registry.enable("mock-workstation"); - const generation = registry.getConnection("mock-workstation")?.observation.generation ?? 0; - const handler = createMtmConnectRpcHandler(registry); - const response = await handler("request", { - args: { - kind: "invoke", - request: { - connectionId: "mock-workstation", - generation, - capabilityId: "workspace.execution", - operationId: "workspace.list", - input: {}, - actor: "user", - }, - }, - }, new AbortController().signal); - - expect(response).toMatchObject({ ok: true, value: { ok: true, simulated: false, summary: "RPC provider read" } }); - }); - - it("normalizes thrown, malformed, and unknown provider failures", async () => { - const throwing = new MtmConnectRegistry({ - ownerId: "user-1", - seed: true, - capabilityInvoker: () => { throw new Error("provider secret"); }, - }); - throwing.enable("mock-workstation"); - const throwingGeneration = throwing.getConnection("mock-workstation")?.observation.generation ?? 0; - await expect(throwing.invokeCapability("mock-workstation", throwingGeneration, "workspace.execution", "workspace.list", {}, "user")) - .resolves.toMatchObject({ ok: false, code: "adapter-unavailable", message: "Adapter execution failed" }); - - const malformed = new MtmConnectRegistry({ - ownerId: "user-1", - seed: true, - capabilityInvoker: (() => ({ ok: true, simulated: true, summary: 42, data: {} })) as unknown as CapabilityInvoker, - }); - malformed.enable("mock-workstation"); - const malformedGeneration = malformed.getConnection("mock-workstation")?.observation.generation ?? 0; - await expect(malformed.invokeCapability("mock-workstation", malformedGeneration, "workspace.execution", "workspace.list", {}, "user")) - .resolves.toMatchObject({ ok: false, code: "adapter-unavailable", message: "Adapter execution failed" }); - - const unknownFailure = new MtmConnectRegistry({ - ownerId: "user-1", - seed: true, - capabilityInvoker: (() => ({ ok: false, code: "provider-error", message: "not a public error" })) as unknown as CapabilityInvoker, - }); - unknownFailure.enable("mock-workstation"); - const unknownGeneration = unknownFailure.getConnection("mock-workstation")?.observation.generation ?? 0; - await expect(unknownFailure.invokeCapability("mock-workstation", unknownGeneration, "workspace.execution", "workspace.list", {}, "user")) - .resolves.toMatchObject({ ok: false, code: "adapter-unavailable", message: "Adapter execution failed" }); - }); -}); diff --git a/packages/mtmharness/src/features/connect/core/registry.test.ts b/packages/mtmharness/src/features/connect/core/registry.test.ts deleted file mode 100644 index 0727afb..0000000 --- a/packages/mtmharness/src/features/connect/core/registry.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createAdapterCatalog } from "../adapters/catalog.ts"; -import { validateAdapterDescriptor } from "../contract/adapter.ts"; -import type { ExternalConnectionEvent } from "../contract/event.ts"; -import { MtmConnectRegistry } from "./registry.ts"; - -function clock(): { now: () => number; advance: (amount?: number) => void } { - let value = 1_700_000_000_000; - return { now: () => value, advance: (amount = 1) => { value += amount; } }; -} - -function event(connectionId: string, capabilityId: string, generation: number, key: string): ExternalConnectionEvent { - return { - eventId: "event-" + key, - connectionId, - capabilityId, - generation, - occurredAt: 1_700_000_000_001, - kind: capabilityId === "device.control" ? "device.notification" : "workspace.changed", - payload: { source: "fixture" }, - dedupeKey: key, - source: "mock-adapter", - }; -} - -describe("mtm-connect registry", () => { - it("starts with user-owned disabled fixture connections", () => { - const registry = new MtmConnectRegistry({ ownerId: "user-1", seed: true, now: clock().now }); - const snapshot = registry.getSnapshot(); - expect(snapshot.ownerId).toBe("user-1"); - expect(snapshot.connections).toHaveLength(2); - expect(snapshot.connections.every((record) => record.instance.desired === "disabled")).toBe(true); - expect(snapshot.connections.every((record) => record.observation.status === "configured")).toBe(true); - }); - - it("fences old channel generations and deduplicates event projections", () => { - const timer = clock(); - const registry = new MtmConnectRegistry({ ownerId: "user-1", seed: true, now: timer.now }); - registry.enable("mock-workstation"); - const online = registry.getConnection("mock-workstation"); - expect(online?.observation).toMatchObject({ status: "online", generation: 1 }); - - const stale = registry.dispatchExternalEvent("mock-workstation", event("mock-workstation", "workspace.execution", 0, "stale")); - expect(stale).toMatchObject({ disposition: "dropped", reason: "stale-generation" }); - - const current = registry.dispatchExternalEvent("mock-workstation", event("mock-workstation", "workspace.execution", 1, "change-1")); - expect(current).toMatchObject({ disposition: "observed", policy: "observe" }); - registry.setCapabilityPolicy("mock-workstation", "workspace.execution", { eventPolicy: "inject-next" }); - const queued = registry.dispatchExternalEvent("mock-workstation", event("mock-workstation", "workspace.execution", 1, "change-2")); - expect(queued).toMatchObject({ disposition: "queued", policy: "inject-next" }); - const duplicate = registry.dispatchExternalEvent("mock-workstation", event("mock-workstation", "workspace.execution", 1, "change-2")); - expect(duplicate).toMatchObject({ disposition: "dropped", reason: "duplicate-dedupe-key" }); - expect(registry.getSnapshot().eventHistory).toHaveLength(4); - }); - - it("supports user-controlled lifecycle and fail-closed invocation policy", async () => { - const registry = new MtmConnectRegistry({ ownerId: "user-1", seed: true, now: clock().now }); - registry.enable("mock-workstation"); - const online = registry.getConnection("mock-workstation"); - const generation = online?.observation.generation ?? 0; - const modelDenied = await registry.invokeCapability("mock-workstation", generation, "workspace.execution", "workspace.list", { path: "/workspace/demo" }, "model"); - expect(modelDenied).toMatchObject({ ok: false, code: "policy-denied" }); - const userResult = await registry.invokeCapability("mock-workstation", generation, "workspace.execution", "workspace.list", { path: "/workspace/demo" }, "user"); - expect(userResult).toMatchObject({ ok: true, simulated: true }); - - registry.setCapabilityPolicy("mock-workstation", "workspace.execution", { modelInvocable: true }); - const modelResult = await registry.invokeCapability("mock-workstation", generation, "workspace.execution", "workspace.list", { path: "/workspace/demo" }, "model"); - expect(modelResult).toMatchObject({ ok: true, simulated: true }); - const stale = await registry.invokeCapability("mock-workstation", generation - 1, "workspace.execution", "workspace.list", {}, "user"); - expect(stale).toMatchObject({ ok: false, code: "stale-generation" }); - - registry.disable("mock-workstation"); - expect(registry.getConnection("mock-workstation")?.observation.status).toBe("offline"); - registry.revoke("mock-workstation"); - expect(registry.getConnection("mock-workstation")?.observation.status).toBe("revoked"); - }); - - it("requires user approval for writes and never accepts model approval", async () => { - const registry = new MtmConnectRegistry({ ownerId: "user-1", seed: true, now: clock().now }); - registry.enable("mock-android"); - const generation = registry.getConnection("mock-android")?.observation.generation ?? 0; - const denied = await registry.invokeCapability("mock-android", generation, "device.control", "input.tap", { x: 10, y: 20 }, "user"); - expect(denied).toMatchObject({ ok: false, code: "approval-required" }); - const modelApproval = await registry.invokeCapability("mock-android", generation, "device.control", "input.tap", { x: 10, y: 20 }, "model", true); - expect(modelApproval).toMatchObject({ ok: false, code: "approval-required" }); - const approved = await registry.invokeCapability("mock-android", generation, "device.control", "input.tap", { x: 10, y: 20 }, "user", true); - expect(approved).toMatchObject({ ok: true, simulated: true }); - }); - - it("rejects secrets, executable descriptors, owner forgery, and stale restores", () => { - const registry = new MtmConnectRegistry({ ownerId: "user-1", seed: false }); - expect(() => registry.createConnection("mock-world", "Bad config", { metadata: { token: "nope" } })).toThrow("credential field"); - const descriptor = createAdapterCatalog()[1]; - expect(() => validateAdapterDescriptor({ ...descriptor, script: "alert(1)" })).toThrow("unsupported field"); - expect(() => validateAdapterDescriptor({ - ...descriptor, - capabilities: descriptor.capabilities.map((capability) => ({ - ...capability, - operations: capability.operations.map((operation) => operation.sideEffect === "write" ? { ...operation, requiresApproval: false } : operation), - })), - })).toThrow("must require approval"); - - const seeded = new MtmConnectRegistry({ ownerId: "user-1", seed: true }); - const forged = JSON.parse(JSON.stringify(seeded.getSnapshot())) as Record; - const connections = forged.connections as Array<{ instance: { ownerId: string } }>; - connections[0]!.instance.ownerId = "other-user"; - expect(() => new MtmConnectRegistry({ ownerId: "user-1", snapshot: forged as never })).toThrow("owner"); - const stale = new MtmConnectRegistry({ ownerId: "user-1", seed: true }).getSnapshot(); - seeded.enable("mock-workstation"); - expect(() => seeded.restoreSnapshot(stale)).toThrow("stale mtm-connect snapshot revision"); - }); -}); diff --git a/packages/mtmharness/src/features/connect/core/registry.ts b/packages/mtmharness/src/features/connect/core/registry.ts deleted file mode 100644 index 067d1a1..0000000 --- a/packages/mtmharness/src/features/connect/core/registry.ts +++ /dev/null @@ -1,590 +0,0 @@ -import { createAdapterCatalog } from "../adapters/catalog.ts"; -import { mockCapabilityInvoker } from "../adapters/mock/invoke.ts"; -import type { CapabilityInvocationContext, CapabilityInvocationExecutionResult, CapabilityInvoker } from "../adapters/invoker.ts"; -import { adapterCapability, createConnectionRecord, type BindingScope, type CapabilityInvocationResult, type ConnectionRecord, type ConnectionSeed, type MtmConnectMutation, type MtmConnectInvocationRequest, type MtmConnectSnapshot } from "../contract/connection.ts"; -import { projectExternalEvent, validateExternalEvent, type EventPolicy, type EventProjection, type ExternalConnectionEvent, type EventRecord } from "../contract/event.ts"; -import { assertPublicConfig, isJsonValue, isRecord, jsonByteLength, type JsonObject } from "../contract/json.ts"; -import { cloneSnapshot, validateSnapshot } from "../contract/snapshot.ts"; -import { validateAdapterDescriptor, type AdapterDescriptor } from "../contract/adapter.ts"; -import { validateMtmControlSnapshot, type MtmControlAdapterDescriptor, type MtmControlInstallationStatus, type MtmControlObservedStatus, type MtmControlScope, type MtmControlSnapshot } from "../contract/control-plane.ts"; - -export type InvocationActor = "model" | "user"; - -export interface MtmConnectRegistryOptions { - readonly ownerId: string; - readonly now?: () => number; - readonly adapters?: readonly AdapterDescriptor[]; - /** Executes an already policy-checked capability without owning policy decisions. */ - readonly capabilityInvoker?: CapabilityInvoker; - readonly seed?: boolean; - readonly snapshot?: MtmConnectSnapshot; - /** Immutable sandbox scope used when projecting the remote control authority. */ - readonly scope?: MtmControlScope; -} - -export type RegistryListener = () => void; - -function copy(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function adapterFailure(): CapabilityInvocationExecutionResult { - return { ok: false, code: "adapter-unavailable", message: "Adapter execution failed" }; -} - -async function invokeCapabilitySafely(invoker: CapabilityInvoker, context: CapabilityInvocationContext): Promise { - let raw: unknown; - try { - raw = await invoker(context); - } catch { - return adapterFailure(); - } - if (!isRecord(raw) || typeof raw.ok !== "boolean") return adapterFailure(); - if (raw.ok) { - if (typeof raw.simulated !== "boolean" || typeof raw.summary !== "string" || !isRecord(raw.data) || !Object.values(raw.data).every(isJsonValue)) { - return adapterFailure(); - } - return { ok: true, simulated: raw.simulated, summary: raw.summary, data: raw.data as JsonObject }; - } - if ((raw.code !== "adapter-unavailable" && raw.code !== "unsupported-operation" && raw.code !== "invalid-input") || typeof raw.message !== "string") { - return adapterFailure(); - } - return { ok: false, code: raw.code, message: raw.message }; -} - -function requiredLabel(label: string): string { - const value = label.trim(); - if (value.length < 2 || value.length > 80) throw new Error("connection label must be between 2 and 80 characters"); - return value; -} - -function adapterFor(adapters: readonly AdapterDescriptor[], id: string): AdapterDescriptor { - const adapter = adapters.find((candidate) => candidate.id === id); - if (adapter === undefined) throw new Error("adapter not found: " + id); - return adapter; -} - -function replaceConnection( - snapshot: MtmConnectSnapshot, - connectionId: string, - update: (record: ConnectionRecord) => ConnectionRecord, -): MtmConnectSnapshot { - let found = false; - const connections = snapshot.connections.map((record) => { - if (record.instance.id !== connectionId) return record; - found = true; - return update(record); - }); - if (!found) throw new Error("connection not found: " + connectionId); - return { ...snapshot, connections }; -} - -function droppedProjection( - event: ExternalConnectionEvent, - policy: EventPolicy, - reason: string, -): EventProjection { - return { eventId: event.eventId, dedupeKey: event.dedupeKey, policy, disposition: "dropped", reason }; -} - -function defaultSeeds(ownerId: string, adapters: readonly AdapterDescriptor[], now: number): ConnectionRecord[] { - const seeds: Array<{ adapterId: string; seed: ConnectionSeed }> = [ - { - adapterId: "mock-world", - seed: { - id: "mock-workstation", - label: "Local workstation (fixture)", - config: { root: "/workspace/demo", transport: "in-memory" }, - fixture: true, - scope: "sandbox", - }, - }, - { - adapterId: "mock-device", - seed: { - id: "mock-android", - label: "Android test device (fixture)", - config: { model: "Pixel 8 fixture", transport: "in-memory" }, - fixture: true, - }, - }, - ]; - return seeds - .map(({ adapterId, seed }) => { - const adapter = adapters.find((candidate) => candidate.id === adapterId && candidate.status === "installed"); - return adapter === undefined ? undefined : createConnectionRecord(ownerId, adapter, seed, now); - }) - .filter((record): record is ConnectionRecord => record !== undefined); -} - -export class MtmConnectRegistry { - private snapshot: MtmConnectSnapshot; - private readonly listeners = new Set(); - private readonly now: () => number; - private readonly capabilityInvoker: CapabilityInvoker; - private disposed = false; - private sequence = 1; - private controlScope: MtmControlScope | undefined; - private controlRevision = -1; - - constructor(options: MtmConnectRegistryOptions) { - if (options.ownerId.trim().length === 0) throw new Error("mtm-connect ownerId is required"); - this.now = options.now ?? (() => Date.now()); - this.capabilityInvoker = options.capabilityInvoker ?? mockCapabilityInvoker; - this.controlScope = options.scope === undefined ? undefined : cloneControlScope(options.scope); - if (options.snapshot !== undefined) { - const restored = validateSnapshot(options.snapshot); - if (restored.ownerId !== options.ownerId) throw new Error("snapshot owner does not match registry owner"); - this.snapshot = cloneSnapshot(restored); - this.controlRevision = restored.controlRevision ?? -1; - this.syncSequence(); - return; - } - const adapters = (options.adapters ?? createAdapterCatalog()).map((adapter) => validateAdapterDescriptor(copy(adapter))); - const createdAt = this.now(); - this.snapshot = { - schemaVersion: 2, - revision: 0, - ownerId: options.ownerId, - adapters, - connections: options.seed === false ? [] : defaultSeeds(options.ownerId, adapters, createdAt), - activeModelProfile: null, - eventHistory: [], - updatedAt: createdAt, - }; - } - - getSnapshot = (): MtmConnectSnapshot => cloneSnapshot(this.snapshot); - - getAdapter(adapterId: string): AdapterDescriptor | undefined { - const adapter = this.snapshot.adapters.find((candidate) => candidate.id === adapterId); - return adapter === undefined ? undefined : copy(adapter); - } - - getConnection(connectionId: string): ConnectionRecord | undefined { - const record = this.snapshot.connections.find((candidate) => candidate.instance.id === connectionId); - return record === undefined ? undefined : copy(record); - } - - getControlRevision(): number { - return this.controlRevision; - } - - /** Apply a remote sandbox snapshot to the local adapter registry. */ - reconcileControlSnapshot(input: MtmControlSnapshot): MtmConnectSnapshot { - this.ensureActive(); - validateMtmControlSnapshot(input); - if (this.controlScope === undefined) throw new Error("control snapshot scope is not bound to registry"); - if (input.scope.owner.subject !== this.snapshot.ownerId) throw new Error("control snapshot owner does not match registry owner"); - if (this.controlScope !== undefined && !sameControlScope(this.controlScope, input.scope)) throw new Error("control snapshot scope does not match registry scope"); - if (input.revision <= this.controlRevision) return this.getSnapshot(); - const connections = input.desiredWorlds.map((world) => this.projectControlWorld(input, world.worldId)); - for (const incoming of connections) { - const current = this.snapshot.connections.find((record) => record.instance.id === incoming.instance.id); - if (current === undefined) continue; - if (incoming.observation.generation < current.observation.generation) throw new Error("control projection generation is stale"); - if (current.observation.status === "online" && incoming.observation.status === "online" - && incoming.observation.generation === current.observation.generation - && incoming.observation.channelId !== current.observation.channelId) { - throw new Error("control projection replaces an active channel without a new generation"); - } - } - const connectionIds = new Set(connections.map((record) => record.instance.id)); - this.commit((snapshot) => ({ - ...snapshot, - controlRevision: input.revision, - activeModelProfile: input.activeModelProfile ?? null, - connections, - eventHistory: snapshot.eventHistory.filter((record) => connectionIds.has(record.event.connectionId)), - })); - this.controlScope = cloneControlScope(input.scope); - this.controlRevision = input.revision; - return this.getSnapshot(); - } - - subscribe(listener: RegistryListener): () => void { - this.listeners.add(listener); - return () => { this.listeners.delete(listener); }; - } - - createConnection( - adapterId: string, - label: string, - config: JsonObject = {}, - scope: BindingScope = "sandbox", - ): ConnectionRecord { - this.ensureActive(); - const adapter = adapterFor(this.snapshot.adapters, adapterId); - if (adapter.status !== "installed") throw new Error("adapter is unavailable: " + adapterId); - assertPublicConfig(config); - const id = this.nextConnectionId(adapterId); - const now = this.now(); - const record = createConnectionRecord(this.snapshot.ownerId, adapter, { - id, - label: requiredLabel(label), - config: copy(config), - fixture: true, - scope, - }, now); - this.commit((snapshot) => ({ ...snapshot, connections: [...snapshot.connections, record] })); - return copy(record); - } - - enable(connectionId: string): void { - this.ensureActive(); - const now = this.now(); - this.commit((snapshot) => replaceConnection(snapshot, connectionId, (record) => { - if (record.observation.status === "revoked") throw new Error("revoked connection cannot be enabled"); - if (!record.instance.fixture) throw new Error("real adapter setup is not implemented in this release"); - const adapter = snapshot.adapters.find((candidate) => candidate.id === record.instance.adapterId); - if (adapter === undefined || adapter.status !== "installed") throw new Error("adapter is unavailable: " + record.instance.adapterId); - const generation = record.observation.generation + 1; - return { - instance: { ...record.instance, desired: "enabled", updatedAt: now }, - observation: { - status: "online", - generation, - channelId: connectionId + ":channel:" + generation, - lastSeenAt: now, - }, - }; - })); - } - - disable(connectionId: string): void { - this.ensureActive(); - const now = this.now(); - this.commit((snapshot) => replaceConnection(snapshot, connectionId, (record) => { - if (record.observation.status === "revoked") return record; - return { - instance: { ...record.instance, desired: "disabled", updatedAt: now }, - observation: { status: "offline", generation: record.observation.generation + 1 }, - }; - })); - } - - revoke(connectionId: string): void { - this.ensureActive(); - const now = this.now(); - this.commit((snapshot) => replaceConnection(snapshot, connectionId, (record) => ({ - instance: { ...record.instance, desired: "disabled", updatedAt: now }, - observation: { - status: "revoked", - generation: record.observation.generation + 1, - lastError: { code: "revoked", message: "Connection was revoked by the owner" }, - }, - }))); - } - - reconnect(connectionId: string): void { - const record = this.requireConnection(connectionId); - if (record.instance.desired !== "enabled") throw new Error("connection must be enabled before reconnecting"); - this.enable(connectionId); - } - - setCapabilityPolicy( - connectionId: string, - capabilityId: string, - patch: Partial>, - ): void { - this.ensureActive(); - if (patch.eventPolicy !== undefined && !["observe", "inject-next", "wake-agent", "require-approval", "disabled"].includes(patch.eventPolicy)) throw new Error("invalid event policy"); - if (patch.enabled !== undefined && typeof patch.enabled !== "boolean") throw new Error("enabled policy must be boolean"); - if (patch.modelInvocable !== undefined && typeof patch.modelInvocable !== "boolean") throw new Error("modelInvocable policy must be boolean"); - if (patch.userInvocable !== undefined && typeof patch.userInvocable !== "boolean") throw new Error("userInvocable policy must be boolean"); - const now = this.now(); - this.commit((snapshot) => replaceConnection(snapshot, connectionId, (record) => { - const binding = record.instance.bindings[capabilityId]; - if (binding === undefined) throw new Error("capability binding not found: " + capabilityId); - return { - ...record, - instance: { - ...record.instance, - updatedAt: now, - bindings: { ...record.instance.bindings, [capabilityId]: { ...binding, ...patch } }, - }, - }; - })); - } - - private projectControlWorld(input: MtmControlSnapshot, worldId: string): ConnectionRecord { - const world = input.desiredWorlds.find((candidate) => candidate.worldId === worldId); - if (world === undefined) throw new Error("control projection world is missing"); - const controlAdapter = input.adapters.find((candidate) => candidate.adapterId === world.adapterId); - const adapter = this.snapshot.adapters.find((candidate) => candidate.id === world.adapterId); - if (controlAdapter === undefined || adapter === undefined) throw new Error("control projection adapter is unavailable"); - if (!controlAdapter.available || adapter.status !== "installed") throw new Error("control projection adapter is unavailable"); - assertControlAdapterCompatibility(controlAdapter, adapter); - const policyIds = Object.keys(world.capabilities); - if (policyIds.length !== adapter.capabilities.length || adapter.capabilities.some((capability) => world.capabilities[capability.id] === undefined)) { - throw new Error("control projection capability policy is incomplete"); - } - const existing = this.snapshot.connections.find((candidate) => candidate.instance.id === world.worldId); - const createdAt = existing?.instance.createdAt ?? this.now(); - const updatedAt = this.now(); - const bindings: Record = {}; - for (const capability of adapter.capabilities) { - const policy = world.capabilities[capability.id]; - if (policy === undefined) throw new Error("control projection capability policy is incomplete"); - bindings[capability.id] = { - capabilityId: capability.id, - enabled: policy.enabled, - modelInvocable: policy.modelInvocable, - userInvocable: policy.userInvocable, - eventPolicy: policy.eventPolicy, - }; - } - const primary = adapter.capabilities.find((capability) => capability.role === "primary-world"); - const observed = input.observedWorlds.find((candidate) => candidate.worldId === world.worldId); - const generation = input.installation?.generation ?? observed?.generation ?? 0; - if (observed !== undefined && observed.generation !== generation) throw new Error("control projection generation mismatch"); - const observedStatus = controlObservedStatus(observed?.status ?? "configured", input.installation?.status, input.installation?.expiresAt, this.now()); - return { - instance: { - id: world.worldId, - ownerId: this.snapshot.ownerId, - adapterId: adapter.id, - label: existing?.instance.label ?? world.worldId, - config: copy(world.config), - desired: world.enabled ? "enabled" : "disabled", - bindings, - ...(primary === undefined ? {} : { worldBinding: { capabilityId: primary.id, scope: existing?.instance.worldBinding?.scope ?? "sandbox", status: "selected" as const } }), - fixture: existing?.instance.fixture ?? true, - createdAt, - updatedAt, - }, - observation: observed === undefined - ? { status: observedStatus, generation, ...(input.installation?.expiresAt === undefined ? {} : { expiresAt: input.installation.expiresAt }) } - : { - status: observedStatus, - generation, - ...(input.installation?.expiresAt === undefined ? {} : { expiresAt: input.installation.expiresAt }), - ...(observedStatus === "online" && observed.channelId !== undefined && observed.lastSeenAt !== undefined - ? { channelId: observed.channelId, lastSeenAt: observed.lastSeenAt } - : {}), - ...(observed.lastError === undefined ? {} : { lastError: observed.lastError }), - }, - }; - } - - dispatchExternalEvent(connectionId: string, eventInput: ExternalConnectionEvent): EventProjection { - this.ensureActive(); - const event = validateExternalEvent(eventInput); - const record = this.requireConnection(connectionId); - const binding = record.instance.bindings[event.capabilityId]; - const policy = binding?.eventPolicy ?? "disabled"; - let projection: EventProjection; - if (event.connectionId !== connectionId) projection = droppedProjection(event, policy, "connection-id-mismatch"); - else if (record.observation.status !== "online") projection = droppedProjection(event, policy, "connection-offline"); - else if (record.observation.expiresAt !== undefined && record.observation.expiresAt <= this.now()) projection = droppedProjection(event, policy, "connection-offline"); - else if (event.generation !== record.observation.generation) projection = droppedProjection(event, policy, "stale-generation"); - else if (binding === undefined || !binding.enabled) projection = droppedProjection(event, policy, "capability-disabled"); - else { - const seen = new Set(recordedDedupeKeys(this.snapshot.eventHistory)); - projection = projectExternalEvent(event, policy, seen); - } - const eventRecord: EventRecord = { event, projection, recordedAt: this.now() }; - this.commit((snapshot) => ({ ...snapshot, eventHistory: [...snapshot.eventHistory, eventRecord] })); - return projection; - } - - applyMutation(mutation: MtmConnectMutation): { readonly snapshot: MtmConnectSnapshot; readonly projection?: EventProjection } { - switch (mutation.type) { - case "create": - this.createConnection(mutation.adapterId, mutation.label, mutation.config, mutation.scope ?? "sandbox"); - return { snapshot: this.getSnapshot() }; - case "enable": - this.enable(mutation.connectionId); - return { snapshot: this.getSnapshot() }; - case "disable": - this.disable(mutation.connectionId); - return { snapshot: this.getSnapshot() }; - case "revoke": - this.revoke(mutation.connectionId); - return { snapshot: this.getSnapshot() }; - case "reconnect": - this.reconnect(mutation.connectionId); - return { snapshot: this.getSnapshot() }; - case "set-policy": - this.setCapabilityPolicy(mutation.connectionId, mutation.capabilityId, mutation.patch); - return { snapshot: this.getSnapshot() }; - case "event": { - const projection = this.dispatchExternalEvent(mutation.connectionId, mutation.event); - return { snapshot: this.getSnapshot(), projection }; - } - } - } - - async invokeCapability( - connectionId: string, - generation: number, - capabilityId: string, - operationId: string, - input: JsonObject, - actor: InvocationActor, - approved = false, - ): Promise { - this.ensureActive(); - const record = this.snapshot.connections.find((candidate) => candidate.instance.id === connectionId); - if (record === undefined) return { ok: false, code: "connection-not-found", message: "Connection does not exist" }; - if (record.observation.status !== "online") return { ok: false, code: "connection-offline", message: "Connection is not online" }; - if (record.observation.expiresAt !== undefined && record.observation.expiresAt <= this.now()) return { ok: false, code: "connection-offline", message: "Connection installation has expired" }; - if (generation !== record.observation.generation) return { ok: false, code: "stale-generation", message: "Connection channel generation is stale" }; - const binding = record.instance.bindings[capabilityId]; - if (binding === undefined) return { ok: false, code: "capability-not-found", message: "Capability is not declared by this connection" }; - if (!binding.enabled) return { ok: false, code: "capability-disabled", message: "Capability is disabled for this connection" }; - if (actor === "model" && !binding.modelInvocable) return { ok: false, code: "policy-denied", message: "Model invocation is disabled by connection policy" }; - if (actor === "user" && !binding.userInvocable) return { ok: false, code: "policy-denied", message: "User invocation is disabled by connection policy" }; - const adapter = this.snapshot.adapters.find((candidate) => candidate.id === record.instance.adapterId); - if (adapter === undefined || adapter.status !== "installed") return { ok: false, code: "adapter-unavailable", message: "Adapter is unavailable" }; - const capability = adapterCapability(adapter, capabilityId); - const operation = capability?.operations.find((candidate) => candidate.id === operationId); - if (capability === undefined || operation === undefined) return { ok: false, code: "unsupported-operation", message: "The selected operation is not declared" }; - if (operation.requiresApproval && (actor !== "user" || !approved)) return { ok: false, code: "approval-required", message: "This operation requires explicit user approval" }; - if (jsonByteLength(input) > capability.limits.maxInputBytes) return { ok: false, code: "input-too-large", message: "Invocation input exceeds the capability limit" }; - const result = await invokeCapabilitySafely(this.capabilityInvoker, { - adapter: copy(adapter), - capability: copy(capability), - operation: copy(operation), - connection: copy(record.instance), - ...(this.snapshot.activeModelProfile === undefined || this.snapshot.activeModelProfile === null - ? {} - : { modelProfile: copy(this.snapshot.activeModelProfile) }), - input: copy(input), - }); - if (!result.ok) return result; - if (jsonByteLength(result.data) > capability.limits.maxOutputBytes) return { ok: false, code: "output-too-large", message: "Invocation output exceeds the capability limit" }; - return { - ok: true, - simulated: result.simulated, - adapterId: adapter.id, - connectionId, - generation, - capabilityId, - operationId, - summary: result.summary, - data: result.data, - }; - } - - async invoke(request: MtmConnectInvocationRequest): Promise { - return this.invokeCapability( - request.connectionId, - request.generation, - request.capabilityId, - request.operationId, - request.input, - request.actor, - request.approved ?? false, - ); - } - - restoreSnapshot(snapshotInput: MtmConnectSnapshot): void { - this.ensureActive(); - const snapshot = validateSnapshot(snapshotInput); - if (snapshot.ownerId !== this.snapshot.ownerId) throw new Error("snapshot owner does not match registry owner"); - if (snapshot.controlRevision !== undefined && snapshot.controlRevision < this.controlRevision) throw new Error("stale mtm-connect control revision"); - if (snapshot.revision <= this.snapshot.revision) throw new Error("stale mtm-connect snapshot revision"); - for (const current of this.snapshot.connections) { - const incoming = snapshot.connections.find((record) => record.instance.id === current.instance.id); - if (incoming === undefined) continue; - if (incoming.observation.generation < current.observation.generation) throw new Error("stale mtm-connect connection generation"); - if (current.observation.status === "online" && incoming.observation.generation === current.observation.generation - && incoming.observation.channelId !== current.observation.channelId) { - throw new Error("mtm-connect snapshot replaces an active channel without a new generation"); - } - } - this.snapshot = cloneSnapshot(snapshot); - if (snapshot.controlRevision !== undefined) this.controlRevision = snapshot.controlRevision; - this.syncSequence(); - this.notify(); - } - - dispose(): void { - if (this.disposed) return; - this.disposed = true; - this.listeners.clear(); - } - - private nextConnectionId(adapterId: string): string { - let id: string; - do { - id = adapterId + "-connection-" + this.sequence++; - } while (this.snapshot.connections.some((record) => record.instance.id === id)); - return id; - } - - private syncSequence(): void { - let maximum = this.sequence - 1; - for (const record of this.snapshot.connections) { - const match = record.instance.id.match(/-connection-(\d+)$/); - if (match !== null) maximum = Math.max(maximum, Number(match[1])); - } - this.sequence = maximum + 1; - } - - private requireConnection(connectionId: string): ConnectionRecord { - const record = this.snapshot.connections.find((candidate) => candidate.instance.id === connectionId); - if (record === undefined) throw new Error("connection not found: " + connectionId); - return record; - } - - private commit(update: (snapshot: MtmConnectSnapshot) => MtmConnectSnapshot): void { - this.ensureActive(); - const next = update(cloneSnapshot(this.snapshot)); - this.snapshot = { ...next, revision: this.snapshot.revision + 1, updatedAt: this.now() }; - this.notify(); - } - - private notify(): void { - for (const listener of [...this.listeners]) listener(); - } - - private ensureActive(): void { - if (this.disposed) throw new Error("mtm-connect registry has been disposed"); - } -} - -function cloneControlScope(scope: MtmControlScope): MtmControlScope { - return copy(scope); -} - -function sameControlScope(left: MtmControlScope, right: MtmControlScope): boolean { - return left.sandboxId === right.sandboxId - && left.workspaceId === right.workspaceId - && left.owner.issuer === right.owner.issuer - && left.owner.subject === right.owner.subject; -} - -function controlObservedStatus( - status: MtmControlObservedStatus, - installationStatus: MtmControlInstallationStatus | undefined, - expiresAt: number | undefined, - now: number, -): ConnectionRecord["observation"]["status"] { - if (status === "revoked" || installationStatus === "revoked") return "revoked"; - if (status === "stale") return "offline"; - if (status === "online" && (installationStatus !== "active" || expiresAt === undefined || expiresAt <= now)) return "offline"; - return status; -} - -function assertControlAdapterCompatibility(control: MtmControlAdapterDescriptor, local: AdapterDescriptor): void { - if (control.adapterId !== local.id || control.version !== local.version) throw new Error("control projection adapter descriptor mismatch"); - if (control.capabilities.length !== local.capabilities.length) throw new Error("control projection adapter capabilities do not match"); - for (const capability of local.capabilities) { - const remote = control.capabilities.find((candidate) => candidate.capabilityId === capability.id); - if (remote === undefined || remote.version !== capability.version || remote.role !== capability.role || remote.operations.length !== capability.operations.length) throw new Error("control projection capability descriptor mismatch"); - for (const operation of capability.operations) { - const remoteOperation = remote.operations.find((candidate) => candidate.operationId === operation.id); - if (remoteOperation === undefined || remoteOperation.sideEffect !== operation.sideEffect || remoteOperation.requiresApproval !== operation.requiresApproval) throw new Error("control projection operation descriptor mismatch"); - } - } -} - -function recordedDedupeKeys(history: readonly EventRecord[]): string[] { - return history.map((record) => record.event.dedupeKey); -} - -export function createDemoRegistry(now?: () => number, scope?: MtmControlScope): MtmConnectRegistry { - return new MtmConnectRegistry({ ownerId: "demo-user", now, seed: true, scope }); -} diff --git a/packages/mtmharness/src/features/connect/index.test.ts b/packages/mtmharness/src/features/connect/index.test.ts deleted file mode 100644 index 40f50b3..0000000 --- a/packages/mtmharness/src/features/connect/index.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { apply } from "./index.ts"; - -describe("mtm-connect Host half", () => { - it("provides the owner registry and serves snapshot/mutation RPCs", async () => { - const provided: Record = {}; - const cleanups: Array<() => void | Promise> = []; - let handler: ((endpoint: string, payload: unknown, signal: AbortSignal) => Promise) | undefined; - const ctx = { - connection: { - rpc: { - handle(_channel: string, next: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise) { - handler = next; - return async () => { handler = undefined; }; - }, - }, - }, - provide(key: string, value: unknown) { provided[key] = value; }, - effect(effect: () => (() => void | Promise) | void) { - const cleanup = effect(); - if (typeof cleanup === "function") cleanups.push(cleanup); - return cleanup; - }, - }; - apply(ctx as never, { ownerId: "owner-1", seed: true }); - expect(provided.mtmConnect).toBeDefined(); - const service = provided.mtmConnect as { getSnapshot: () => { ownerId: string; revision: number; connections: readonly { instance: { id: string }; observation: { status: string } }[] } }; - expect(service.getSnapshot()).toMatchObject({ ownerId: "owner-1", revision: 0 }); - expect(service.getSnapshot().connections).toHaveLength(2); - expect(handler).toBeTypeOf("function"); - - const signal = new AbortController().signal; - const snapshotResponse = await handler!("request", { args: { kind: "snapshot" } }, signal) as { ok: boolean; value: { ownerId: string } }; - expect(snapshotResponse).toMatchObject({ ok: true, value: { ownerId: "owner-1" } }); - const mutationResponse = await handler!("request", { - args: { kind: "mutate", mutation: { type: "enable", connectionId: "mock-workstation" } }, - }, signal) as { ok: boolean; value: { snapshot: { revision: number } } }; - expect(mutationResponse).toMatchObject({ ok: true, value: { snapshot: { revision: 1 } } }); - expect(service.getSnapshot().connections[0]?.observation.status).toBe("online"); - - for (const cleanup of cleanups.reverse()) await cleanup(); - expect(handler).toBeUndefined(); - }); -}); diff --git a/packages/mtmharness/src/features/connect/index.ts b/packages/mtmharness/src/features/connect/index.ts deleted file mode 100644 index 8c952f5..0000000 --- a/packages/mtmharness/src/features/connect/index.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { Context } from "@deepseek-ai/cordis"; -import type {} from "@deepseek-ai/dsh-client-connection"; -import type { CapabilityInvoker } from "./adapters/invoker.ts"; -import { MtmConnectRegistry, type MtmConnectRegistryOptions } from "./core/registry.ts"; -import type { MtmConnectSnapshot } from "./contract/connection.ts"; -import type { MtmControlScope, MtmControlSnapshot } from "./contract/control-plane.ts"; -import { MTM_CONNECT_CHANNEL, parseMtmConnectRpcRequest } from "./contract/rpc.ts"; - -type RpcResult = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: { readonly code: "internal"; readonly message: string; readonly details: Record } }; - -interface MtmConnectHostConfig { - readonly ownerId?: string; - readonly seed?: boolean; - readonly scope?: MtmControlScope; - /** Supplies a local adapter executor; it must not bypass registry policy checks. */ - readonly capabilityInvoker?: CapabilityInvoker; - /** Only a trusted control-plane bridge may enable this privileged RPC. */ - readonly allowControlReconcile?: boolean; -} - -interface MtmConnectHostService { - readonly registry: MtmConnectRegistry; - getSnapshot(): MtmConnectSnapshot; - restoreSnapshot(snapshot: MtmConnectSnapshot): void; - reconcileControlSnapshot(snapshot: MtmControlSnapshot): MtmConnectSnapshot; -} - -declare module "@deepseek-ai/cordis" { - interface Context { - mtmConnect: MtmConnectHostService; - } -} - -function hostService(registry: MtmConnectRegistry): MtmConnectHostService { - return { - registry, - getSnapshot: registry.getSnapshot, - restoreSnapshot: (snapshot) => { registry.restoreSnapshot(snapshot); }, - reconcileControlSnapshot: (snapshot) => registry.reconcileControlSnapshot(snapshot), - }; -} - -function failure(error: unknown): RpcResult { - return { - ok: false, - error: { - code: "internal", - message: error instanceof Error ? error.message : String(error), - details: {}, - }, - }; -} - -export function createMtmConnectRpcHandler(registry: MtmConnectRegistry, options: { readonly allowControlReconcile?: boolean } = {}): (endpoint: string, payload: unknown, signal: AbortSignal) => Promise> { - return async (endpoint, payload, _signal) => { - if (endpoint !== "request") return failure(new Error("mtm-connect: unknown RPC endpoint")); - try { - const request = parseMtmConnectRpcRequest((payload as { args?: unknown } | null)?.args); - if (request.kind === "snapshot") return { ok: true, value: registry.getSnapshot() }; - if (request.kind === "mutate") return { ok: true, value: registry.applyMutation(request.mutation) }; - if (request.kind === "reconcile") { - if (options.allowControlReconcile !== true) throw new Error("mtm-connect control reconciliation is restricted"); - return { ok: true, value: { snapshot: registry.reconcileControlSnapshot(request.snapshot) } }; - } - return { ok: true, value: await registry.invoke(request.request) }; - } catch (error) { - return failure(error); - } - }; -} - -/** Install the Host-owned registry and expose it over DSH's loopback RPC seam. */ -export function apply(ctx: Context, config: MtmConnectHostConfig = {}): void { - const options: MtmConnectRegistryOptions = { - ownerId: config.ownerId ?? "local-demo-user", - seed: config.seed ?? true, - scope: config.scope, - capabilityInvoker: config.capabilityInvoker, - }; - const registry = new MtmConnectRegistry(options); - ctx.provide("mtmConnect", hostService(registry)); - ctx.effect(() => { - const remove = ctx.connection.rpc.handle( - MTM_CONNECT_CHANNEL, - createMtmConnectRpcHandler(registry, { allowControlReconcile: config.allowControlReconcile === true }), - { authority: "loopback" }, - ); - return async () => { - await remove(); - registry.dispose(); - }; - }, "mtm-connect: Host registry and RPC"); -} diff --git a/packages/mtmharness/src/features/mtm-connect/client/MtmConnectCard.tsx b/packages/mtmharness/src/features/mtm-connect/client/MtmConnectCard.tsx new file mode 100644 index 0000000..5b55320 --- /dev/null +++ b/packages/mtmharness/src/features/mtm-connect/client/MtmConnectCard.tsx @@ -0,0 +1,80 @@ +import { useState, type CSSProperties } from "react"; +import type { InjectFace, PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; +import type { MtmConnectCardFace, MtmConnectCardState } from "./controller.js"; +import type { MtmConnectLocaleKey } from "./locales.js"; +import type {} from "@deepseek-ai/dsh-client-ui-settings-plugins/client"; + +export type MtmConnectCardProps = + PropsRuntime<"settings.plugin.item"> + & PropsLocale<"mtm.connect"> + & InjectFace; + +const cardStyle: CSSProperties = { + border: "1px solid color-mix(in srgb, currentColor 16%, transparent)", + borderRadius: 6, + listStyle: "none", + margin: "0 0 12px", + overflow: "hidden", +}; +const headerStyle: CSSProperties = { + alignItems: "center", + background: "transparent", + border: 0, + color: "inherit", + cursor: "pointer", + display: "flex", + justifyContent: "space-between", + padding: "12px 14px", + textAlign: "left", + width: "100%", +}; +const bodyStyle: CSSProperties = { borderTop: "1px solid color-mix(in srgb, currentColor 12%, transparent)", padding: "12px 14px 14px" }; +const actionStyle: CSSProperties = { display: "flex", flexWrap: "wrap", gap: 8, justifyContent: "flex-end", paddingTop: 14 }; +const buttonStyle: CSSProperties = { border: "1px solid color-mix(in srgb, currentColor 22%, transparent)", borderRadius: 4, cursor: "pointer", padding: "6px 10px" }; + +function statusLabel(t: (key: MtmConnectLocaleKey) => string, state: MtmConnectCardState): string { + if (state.status === "disabled") return t("statusDisabled"); + if (state.status === "loading") return t("statusLoading"); + if (state.status === "failed") return t("statusFailed"); + return t("statusEnabled"); +} + +export function MtmConnectCard(props: MtmConnectCardProps) { + const t = props.t; + const state = props.useMtmConnectCard((snapshot: MtmConnectCardState) => snapshot); + const [open, setOpen] = useState(false); + if (!state.available) return null; + const disabled = !state.writable; + return ( +
  • + + {open ? ( +
    + {disabled ?

    {t("readOnly")}

    : null} + {state.error ?

    {state.error}

    : null} +
    + + {state.overridden ? : null} +
    +

    {t("enabledHint")}

    +
    + {state.dirty ? {t("unsaved")} : null} + {state.failed ? {t("saveFailed")} : null} + + + +
    +
    + ) : null} +
  • + ); +} diff --git a/packages/mtmharness/src/features/mtm-connect/client/controller.test.ts b/packages/mtmharness/src/features/mtm-connect/client/controller.test.ts new file mode 100644 index 0000000..7556cc8 --- /dev/null +++ b/packages/mtmharness/src/features/mtm-connect/client/controller.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; +import { MtmSecondaryClientRuntime } from "../../secondary/client.ts"; +import { MtmConnectCardController } from "./controller.ts"; + +type SettingsSnapshot = { + status: "ready"; + value: { enabled: boolean }; + base: { enabled: boolean }; + user: Record; + revision: number; + writable: boolean; + mode: "host"; +}; + +function bench() { + let snapshot: SettingsSnapshot = { + status: "ready", + value: { enabled: true }, + base: { enabled: true }, + user: {}, + revision: 0, + writable: true, + mode: "host", + }; + const settingsListeners = new Set<() => void>(); + const publish = (enabled: boolean): void => { + snapshot = { ...snapshot, value: { enabled }, user: enabled === true ? { enabled } : {}, revision: snapshot.revision + 1 }; + for (const listener of [...settingsListeners]) listener(); + }; + const settings = { + getSnapshot: () => snapshot, + subscribe(listener: () => void) { + settingsListeners.add(listener); + return () => { settingsListeners.delete(listener); }; + }, + async set(_field: string, enabled: boolean) { + publish(enabled); + }, + async unset() { + publish(true); + }, + }; + let runtimeState = { desired: false, status: "disabled" as const }; + const runtimeListeners = new Set<() => void>(); + const runtime = { + getSnapshot: () => runtimeState, + subscribe(listener: () => void) { + runtimeListeners.add(listener); + return () => { runtimeListeners.delete(listener); }; + }, + setEnabled: vi.fn(async (enabled: boolean) => { + runtimeState = { desired: enabled, status: enabled ? "enabled" : "disabled" }; + for (const listener of [...runtimeListeners]) listener(); + }), + show: vi.fn(), + dispose: vi.fn(async () => {}), + }; + return { settings, runtime, publish }; +} + +describe("mtm-connect settings controller", () => { + it("enables the runtime by default, saves disable, opens, and disposes", async () => { + const state = bench(); + const controller = new MtmConnectCardController(state.settings as never, state.runtime as never); + const face = controller.inject(); + await vi.waitFor(() => { expect(state.runtime.setEnabled).toHaveBeenCalledWith(true); }); + + face.edit(false); + expect(face.hooks.mtmConnectCard.getSnapshot()).toMatchObject({ enabled: false, dirty: true }); + face.save(); + await vi.waitFor(() => { expect(face.hooks.mtmConnectCard.getSnapshot()).toMatchObject({ enabled: false, dirty: false, failed: false }); }); + expect(state.runtime.setEnabled).toHaveBeenCalledWith(false); + + face.open(); + expect(state.runtime.show).toHaveBeenCalledOnce(); + await controller.dispose(); + expect(state.runtime.dispose).toHaveBeenCalledOnce(); + }); + + it("removes the loaded root after rapid false-true-false setting changes", async () => { + document.body.replaceChildren(); + const manifest = { + apiVersion: 1, + id: "mtm-connect", + version: "0.2.0", + clientUrl: "https://static.example.test/mtm-connect.js", + clientIntegrity: "sha256-" + "A".repeat(43) + "=", + } as const; + const runtime = new MtmSecondaryClientRuntime({ + document, + fetch: async () => new Response("export function mount() {}", { status: 200 }), + digest: async () => manifest.clientIntegrity, + importModule: async () => ({ + mount: ({ root }: { root: HTMLElement }) => { + root.textContent = "mounted"; + return () => {}; + }, + }), + }, manifest); + const state = bench(); + const controller = new MtmConnectCardController(state.settings as never, runtime); + await vi.waitFor(() => { expect(runtime.getSnapshot()).toMatchObject({ desired: true, status: "enabled" }); }); + + state.publish(false); + state.publish(true); + state.publish(false); + await vi.waitFor(() => { expect(runtime.getSnapshot()).toEqual({ desired: false, status: "disabled" }); }); + expect(document.querySelector('[data-mtm-secondary-extension="mtm-connect"]')).toBeNull(); + await controller.dispose(); + }); +}); diff --git a/packages/mtmharness/src/features/mtm-connect/client/controller.ts b/packages/mtmharness/src/features/mtm-connect/client/controller.ts new file mode 100644 index 0000000..e3c6058 --- /dev/null +++ b/packages/mtmharness/src/features/mtm-connect/client/controller.ts @@ -0,0 +1,159 @@ +import type { SettingsScope, SnapshotStore } from "@deepseek-ai/dsh-client-runtime/client"; +import { createSnapshotStore } from "@deepseek-ai/dsh-client-runtime/client"; +import type { MtmSecondaryClientRuntime, MtmSecondarySnapshot } from "../../secondary/client.js"; +import type { MtmConnectSettings } from "../index.js"; + +export interface MtmConnectCardState { + readonly available: boolean; + readonly writable: boolean; + readonly enabled: boolean; + readonly overridden: boolean; + readonly dirty: boolean; + readonly saving: boolean; + readonly failed: boolean; + readonly status: MtmSecondarySnapshot["status"]; + readonly error?: string; +} + +export interface MtmConnectCardFace { + readonly hooks: { mtmConnectCard: SnapshotStore }; + readonly edit: (enabled: boolean) => void; + readonly save: () => void; + readonly discard: () => void; + readonly reset: () => void; + readonly open: () => void; +} + +type Staged = { readonly enabled: boolean; readonly clear: boolean }; + +type ConnectSettingsSnapshot = ReturnType["getSnapshot"]>; + +function resolvedEnabled(snapshot: ConnectSettingsSnapshot): boolean { + return (snapshot.value as Record | undefined)?.enabled === true; +} + +function baseEnabled(snapshot: ConnectSettingsSnapshot): boolean { + return ((snapshot.base as Record | undefined)?.enabled ?? true) === true; +} + +function userHasEnabled(snapshot: ConnectSettingsSnapshot): boolean { + const user = snapshot.user as Record | undefined; + return user !== undefined && Object.hasOwn(user, "enabled"); +} + +/** Staged settings card and live secondary-extension lifecycle. */ +export class MtmConnectCardController { + private staged: Staged | undefined; + private readonly store: SnapshotStore; + private saving = false; + private failed = false; + private disposed = false; + private readonly stopSettings: () => void; + private readonly stopRuntime: () => void; + private reconciling = Promise.resolve(); + + constructor( + private readonly scope: SettingsScope, + private readonly runtime: MtmSecondaryClientRuntime, + ) { + this.store = createSnapshotStore(this.projection()); + this.stopSettings = scope.subscribe(() => { + this.publish(); + void this.queueReconcile().catch(() => { this.failed = true; this.publish(); }); + }); + this.stopRuntime = runtime.subscribe(() => { this.publish(); }); + void this.queueReconcile().catch(() => { this.failed = true; this.publish(); }); + } + + inject(): MtmConnectCardFace { + return { + hooks: { mtmConnectCard: this.store }, + edit: (enabled) => { this.edit(enabled); }, + save: () => { void this.save(); }, + discard: () => { this.discard(); }, + reset: () => { this.reset(); }, + open: () => { this.runtime.show("[data-mtm-connect-focus]"); }, + }; + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + this.stopSettings(); + this.stopRuntime(); + await this.reconciling; + await this.runtime.dispose(); + } + + private edit(enabled: boolean): void { + this.staged = { enabled, clear: false }; + this.failed = false; + this.publish(); + } + + private discard(): void { + this.staged = undefined; + this.failed = false; + this.publish(); + } + + private reset(): void { + this.staged = { enabled: baseEnabled(this.scope.getSnapshot()), clear: true }; + this.failed = false; + this.publish(); + } + + private async save(): Promise { + const staged = this.staged; + if (this.saving || staged === undefined || !this.scope.getSnapshot().writable) return; + this.saving = true; + this.failed = false; + this.publish(); + try { + if (staged.clear) await this.scope.unset("enabled"); + else await this.scope.set("enabled", staged.enabled); + const snapshot = this.scope.getSnapshot(); + if (staged.clear ? userHasEnabled(snapshot) : resolvedEnabled(snapshot) !== staged.enabled) throw new Error("MTM Connect setting was not accepted"); + this.staged = undefined; + await this.queueReconcile(); + } catch { + this.failed = true; + } finally { + this.saving = false; + this.publish(); + } + } + + private queueReconcile(): Promise { + const operation = this.reconciling.then(() => this.reconcile(), () => this.reconcile()); + this.reconciling = operation.then(() => undefined, () => undefined); + return operation; + } + + private async reconcile(): Promise { + if (this.disposed) return; + await this.runtime.setEnabled(resolvedEnabled(this.scope.getSnapshot())); + this.publish(); + } + + private projection(): MtmConnectCardState { + const snapshot = this.scope.getSnapshot(); + const staged = this.staged; + const enabled = staged?.enabled ?? resolvedEnabled(snapshot); + return { + available: snapshot.status === "ready", + writable: snapshot.writable, + enabled, + overridden: staged?.clear === true ? false : staged !== undefined || userHasEnabled(snapshot), + dirty: staged !== undefined, + saving: this.saving, + failed: this.failed, + status: this.runtime.getSnapshot().status, + error: this.runtime.getSnapshot().error, + }; + } + + private publish(): void { + if (!this.disposed) this.store.set(this.projection()); + } +} diff --git a/packages/mtmharness/src/features/mtm-connect/client/index.tsx b/packages/mtmharness/src/features/mtm-connect/client/index.tsx new file mode 100644 index 0000000..522edf6 --- /dev/null +++ b/packages/mtmharness/src/features/mtm-connect/client/index.tsx @@ -0,0 +1,35 @@ +import type { ClientContext } from "@deepseek-ai/dsh-client-runtime/client"; +import type {} from "@deepseek-ai/dsh-client-locale/client"; +import type {} from "@deepseek-ai/dsh-client-ui-settings-plugins/client"; +import { MtmConnectCard } from "./MtmConnectCard.js"; +import { MtmConnectCardController } from "./controller.js"; +import { en, zh, type MtmConnectLocaleKey } from "./locales.js"; +import { MtmSecondaryClientRuntime } from "../../secondary/client.js"; +import { MTM_CONNECT_EXTENSION } from "../../secondary/manifest.js"; +import { SETTINGS_NAMESPACE } from "../contract.js"; +import type { MtmConnectSettings } from "../index.js"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + "mtm.connect": MtmConnectLocaleKey; + } +} + +export const name = "mtm-connect-client"; +export const inject = ["slots", "locale", "settingsScope"]; + +/** Register the settings card and runtime-loaded Connect frontend. */ +export function apply(ctx: ClientContext): void { + const settings = ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE }); + const runtime = new MtmSecondaryClientRuntime({ document: typeof document === "undefined" ? undefined : document }, MTM_CONNECT_EXTENSION); + const controller = new MtmConnectCardController(settings, runtime); + const t = ctx.locale.bind("mtm.connect"); + ctx.effect(() => ctx.locale.register("mtm.connect", { en, zh }), "mtm-connect: locale"); + ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({ + name: "settings.plugin.item", + key: SETTINGS_NAMESPACE, + locale: "mtm.connect", + inject: () => controller.inject(), + }, (props) => )); + ctx.effect(() => async () => { await controller.dispose(); }, "mtm-connect: client lifecycle"); +} diff --git a/packages/mtmharness/src/features/mtm-connect/client/locales.ts b/packages/mtmharness/src/features/mtm-connect/client/locales.ts new file mode 100644 index 0000000..b41866a --- /dev/null +++ b/packages/mtmharness/src/features/mtm-connect/client/locales.ts @@ -0,0 +1,61 @@ +export type MtmConnectLocaleKey = + | "title" + | "description" + | "enabled" + | "enabledHint" + | "reset" + | "statusDisabled" + | "statusLoading" + | "statusEnabled" + | "statusFailed" + | "open" + | "save" + | "saving" + | "discard" + | "unsaved" + | "saveFailed" + | "readOnly" + | "show" + | "hide"; + +export const en: Record = { + title: "MTM Connect", + description: "Device and execution-world connection frontend.", + enabled: "Enabled", + enabledHint: "Load the pinned Connect frontend at runtime.", + reset: "Reset", + statusDisabled: "Disabled", + statusLoading: "Loading", + statusEnabled: "Ready", + statusFailed: "Failed", + open: "Open Connect", + save: "Save", + saving: "Saving...", + discard: "Discard", + unsaved: "Unsaved", + saveFailed: "The setting could not be saved; your edit was kept.", + readOnly: "This deployment stores settings read-only.", + show: "Show settings", + hide: "Hide settings", +}; + +export const zh: Record = { + title: "MTM Connect", + description: "设备与执行世界连接前端。", + enabled: "启用", + enabledHint: "运行时加载固定版本的 Connect 前端。", + reset: "恢复默认", + statusDisabled: "已禁用", + statusLoading: "加载中", + statusEnabled: "就绪", + statusFailed: "失败", + open: "打开 Connect", + save: "保存", + saving: "保存中...", + discard: "放弃修改", + unsaved: "未保存", + saveFailed: "设置保存失败,修改仍保留供你修正。", + readOnly: "本部署的设置为只读。", + show: "展开设置", + hide: "收起设置", +}; diff --git a/packages/mtmharness/src/features/mtm-connect/contract.ts b/packages/mtmharness/src/features/mtm-connect/contract.ts new file mode 100644 index 0000000..9141d71 --- /dev/null +++ b/packages/mtmharness/src/features/mtm-connect/contract.ts @@ -0,0 +1 @@ +export const SETTINGS_NAMESPACE = "mtm-connect"; diff --git a/packages/mtmharness/src/features/mtm-connect/index.test.ts b/packages/mtmharness/src/features/mtm-connect/index.test.ts new file mode 100644 index 0000000..2d652f3 --- /dev/null +++ b/packages/mtmharness/src/features/mtm-connect/index.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { apply, MtmConnectSettingsSchema, SETTINGS_NAMESPACE } from "./index.ts"; + +describe("mtm-connect Host settings", () => { + it("registers an enabled-by-default namespace", () => { + let registration: { namespace: unknown; schema: unknown; options: unknown } | undefined; + apply({ + settings: { + register(namespace: unknown, schema: unknown, options: unknown) { + registration = { namespace, schema, options }; + return {}; + }, + }, + } as never); + expect(registration).toMatchObject({ namespace: SETTINGS_NAMESPACE, schema: MtmConnectSettingsSchema, options: { base: { enabled: true } } }); + expect(MtmConnectSettingsSchema({})).toMatchObject({ enabled: true }); + }); +}); diff --git a/packages/mtmharness/src/features/mtm-connect/index.ts b/packages/mtmharness/src/features/mtm-connect/index.ts new file mode 100644 index 0000000..830185b --- /dev/null +++ b/packages/mtmharness/src/features/mtm-connect/index.ts @@ -0,0 +1,23 @@ +import type { Context } from "@deepseek-ai/cordis"; +import { settingsNamespace } from "@deepseek-ai/dsh-settings"; +import z from "@deepseek-ai/schemastery"; +import { SETTINGS_NAMESPACE } from "./contract.ts"; +export { SETTINGS_NAMESPACE } from "./contract.ts"; + +export interface MtmConnectSettings { + enabled: boolean; +} + +export type MtmConnectConfig = Partial; + +export const MtmConnectSettingsSchema: z = z.object({ + enabled: z.boolean().default(true), +}); + +export const name = "mtm-connect"; +export const inject = ["settings"]; + +/** Register the user-owned setting for the secondary Connect frontend. */ +export function apply(ctx: Context, rawConfig: MtmConnectConfig = {}): void { + ctx.settings.register(settingsNamespace(SETTINGS_NAMESPACE), MtmConnectSettingsSchema, { base: { enabled: rawConfig.enabled ?? true } }); +} diff --git a/packages/mtmharness/src/features/secondary/client.test.ts b/packages/mtmharness/src/features/secondary/client.test.ts index 8377594..88bc8c9 100644 --- a/packages/mtmharness/src/features/secondary/client.test.ts +++ b/packages/mtmharness/src/features/secondary/client.test.ts @@ -167,6 +167,33 @@ describe("secondary extension lifecycle", () => { expect(state.cleanup).toHaveBeenCalledTimes(2); }); + it("replays show requested while loading after mount", async () => { + document.body.replaceChildren(); + let resolveImport!: (value: unknown) => void; + const importing = new Promise((resolve) => { resolveImport = resolve; }); + const runtime = new MtmSecondaryClientRuntime({ + document, + fetch: async () => new Response("export function mount() {}", { status: 200 }), + digest: async () => MANIFEST.clientIntegrity, + importModule: async () => importing, + }, MANIFEST); + const enabling = runtime.setEnabled(true); + await vi.waitFor(() => { expect(runtime.getSnapshot().status).toBe("loading"); }); + runtime.show("[data-secondary-focus]"); + resolveImport({ + mount: ({ root }: { root: HTMLElement }) => { + root.hidden = true; + root.innerHTML = ""; + return () => {}; + }, + }); + await enabling; + const root = document.querySelector("[data-mtm-secondary-extension=mtmcanvas]"); + expect(root?.hidden).toBe(false); + expect(document.activeElement).toBe(root?.querySelector("[data-secondary-focus]")); + await runtime.dispose(); + }); + it("does not mount after disposal", async () => { const state = bench(); await state.runtime.dispose(); diff --git a/packages/mtmharness/src/features/secondary/client.ts b/packages/mtmharness/src/features/secondary/client.ts index 2410eeb..f766f42 100644 --- a/packages/mtmharness/src/features/secondary/client.ts +++ b/packages/mtmharness/src/features/secondary/client.ts @@ -125,6 +125,8 @@ export class MtmSecondaryClientRuntime { private root: HTMLDivElement | undefined; private cleanup: Cleanup | undefined; private loadAbort: AbortController | undefined; + private pendingShow = false; + private pendingFocusSelector: string | undefined; private queue: Promise = Promise.resolve(); private disposed = false; @@ -145,16 +147,37 @@ export class MtmSecondaryClientRuntime { setEnabled(enabled: boolean): Promise { if (this.disposed) return Promise.resolve(); this.desired = enabled; - if (!enabled) this.loadAbort?.abort(); + if (!enabled) { + this.pendingShow = false; + this.pendingFocusSelector = undefined; + this.loadAbort?.abort(); + } const operation = this.queue.then(() => this.reconcile()); this.queue = operation.then(() => undefined, () => undefined); return operation; } + show(focusSelector?: string): void { + const root = this.root; + if (root === undefined) { + if (this.desired) { + this.pendingShow = true; + this.pendingFocusSelector = focusSelector; + } + return; + } + this.pendingShow = false; + this.pendingFocusSelector = undefined; + root.hidden = false; + if (focusSelector !== undefined) root.querySelector(focusSelector)?.focus(); + } + async dispose(): Promise { if (this.disposed) return; this.disposed = true; this.desired = false; + this.pendingShow = false; + this.pendingFocusSelector = undefined; this.loadAbort?.abort(); await this.queue; try { @@ -249,6 +272,12 @@ export class MtmSecondaryClientRuntime { if (typeof mountedCleanup === "function") returnedCleanup = mountedCleanup; else if (mountedCleanup !== undefined) throw new Error("secondary extension mount must return a cleanup function"); acceptingCleanups = false; + if (this.pendingShow) { + const focusSelector = this.pendingFocusSelector; + this.pendingShow = false; + this.pendingFocusSelector = undefined; + this.show(focusSelector); + } if (this.disposed || !this.desired) await this.unload(); } finally { if (this.loadAbort === controller) this.loadAbort = undefined; @@ -262,6 +291,8 @@ export class MtmSecondaryClientRuntime { if (cleanup !== undefined) await cleanup(); } finally { root?.remove(); + this.pendingShow = false; + this.pendingFocusSelector = undefined; } this.cleanup = undefined; this.root = undefined; @@ -273,7 +304,7 @@ export class MtmSecondaryClientRuntime { } } -/** Mount the secondary controller under the primary mtmharness client fiber. */ +/** Mount the Canvas secondary extension under the primary mtmharness client fiber. */ export function apply(ctx: ClientContext): void { const settingsScope = ctx.settingsScope as SecondarySettingsBinder | undefined; if (settingsScope === undefined) throw new Error("mtmharness: secondary settings service is unavailable"); diff --git a/packages/mtmharness/src/features/secondary/manifest.ts b/packages/mtmharness/src/features/secondary/manifest.ts index f8e1c11..169c856 100644 --- a/packages/mtmharness/src/features/secondary/manifest.ts +++ b/packages/mtmharness/src/features/secondary/manifest.ts @@ -52,3 +52,12 @@ export const MTM_CANVAS_EXTENSION = { clientUrl: "https://unpkg.com/mtmcanvas@0.2.0/lib/client.js", clientIntegrity: "sha256-TDJa0tdb9LK87hCigE0aruLJnuNqRG7Ls2UfuHWsKU4=", } as const satisfies MtmSecondaryExtensionManifest; + +/** The published mock Connect artifact used by the first device UI release. */ +export const MTM_CONNECT_EXTENSION = { + apiVersion: 1, + id: "mtm-connect", + version: "0.2.0", + clientUrl: "https://unpkg.com/mtm-connect@0.2.0/lib/client.js", + clientIntegrity: "sha256-P2U7C8ILLZHxih3e4GJKqctv8/2hBiAtkJ6ZoDUGSa8=", +} as const satisfies MtmSecondaryExtensionManifest; diff --git a/packages/mtmharness/src/index.ts b/packages/mtmharness/src/index.ts index 33d499a..93ae074 100644 --- a/packages/mtmharness/src/index.ts +++ b/packages/mtmharness/src/index.ts @@ -1,7 +1,8 @@ /** Host assembly entry for the unified mtmharness DSH plugin. */ import type { Context } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-client-connection"; import { apply as applyCodingHost } from "./features/coding/index.ts"; -import { apply as applyConnectHost } from "./features/connect/index.ts"; +import { apply as applyMtmConnectSettings } from "./features/mtm-connect/index.ts"; import { apply as applyUpdateHost } from "./features/update/index.ts"; export { buildMcpConfig, resolveConfig } from "./features/coding/index.ts"; @@ -17,6 +18,8 @@ export { resolveWorkingDirectory, } from "./features/coding/runtime.ts"; export { apply as applyCoding } from "./features/coding/index.ts"; +export { MtmConnectSettingsSchema, SETTINGS_NAMESPACE as MTM_CONNECT_SETTINGS_NAMESPACE } from "./features/mtm-connect/index.ts"; +export type { MtmConnectConfig, MtmConnectSettings } from "./features/mtm-connect/index.ts"; export { apply as applyCodebaseMemory } from "./features/coding/codebase-memory.ts"; export { apply as applyModernGo } from "./features/coding/modern-go.ts"; export { apply as applyPonytail } from "./features/coding/ponytail.ts"; @@ -48,7 +51,7 @@ export const inject = ["connection", "settings", "subprocess"]; /** Mount the Host-owned MTM and coding control planes. */ export async function apply(ctx: Context, config: Record = {}): Promise { if (ctx.connection === undefined) throw new Error("mtmharness: DSH connection service is unavailable"); - applyConnectHost(ctx); + applyMtmConnectSettings(ctx, typeof config["mtm-connect"] === "object" && config["mtm-connect"] !== null ? config["mtm-connect"] as { enabled?: boolean } : {}); applyUpdateHost(ctx); await applyCodingHost(ctx, config); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ff2884..1fe18db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,36 @@ importers: specifier: 6.0.3 version: 6.0.3 + packages/mtm-connect: + devDependencies: + '@types/node': + specifier: 22.20.1 + version: 22.20.1 + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + esbuild: + specifier: 0.28.2 + version: 0.28.2 + jsdom: + specifier: 30.0.1 + version: 30.0.1 + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 4.1.11 + version: 4.1.11(@types/node@22.20.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)) + packages/mtmcanvas: devDependencies: '@types/node':