From c64a2afca2a2593cf481490ce6292d844c790f61 Mon Sep 17 00:00:00 2001 From: a Date: Fri, 28 Aug 2026 07:26:47 +0000 Subject: [PATCH 1/2] feat(mtmcanvas): restore independent DSH plugin experiment --- packages/mtmcanvas/LICENSE | 0 packages/mtmcanvas/README.md | 34 +++ packages/mtmcanvas/cordis.patch.yml | 4 + packages/mtmcanvas/package.json | 99 +++++++++ packages/mtmcanvas/scripts/build.mjs | 64 ++++++ packages/mtmcanvas/scripts/pack-check.mjs | 18 ++ packages/mtmcanvas/scripts/verify-package.mjs | 55 +++++ packages/mtmcanvas/src/client/CanvasView.tsx | 110 ++++++++++ .../mtmcanvas/src/client/MtmCanvasAction.tsx | 45 ++++ packages/mtmcanvas/src/client/index.ts | 31 +++ packages/mtmcanvas/src/client/runtime.test.ts | 72 ++++++ packages/mtmcanvas/src/client/runtime.ts | 207 ++++++++++++++++++ packages/mtmcanvas/src/client/styles.ts | 29 +++ .../mtmcanvas/src/contract/canvas.test.ts | 26 +++ packages/mtmcanvas/src/contract/canvas.ts | 98 +++++++++ packages/mtmcanvas/src/contract/rpc.ts | 37 ++++ packages/mtmcanvas/src/index.test.ts | 90 ++++++++ packages/mtmcanvas/src/index.ts | 120 ++++++++++ .../mtmcanvas/tests/package-contract.test.ts | 19 ++ packages/mtmcanvas/tsconfig.json | 35 +++ packages/mtmcanvas/vitest.config.ts | 8 + pnpm-lock.yaml | 54 +++++ 22 files changed, 1255 insertions(+) create mode 100644 packages/mtmcanvas/LICENSE create mode 100644 packages/mtmcanvas/README.md create mode 100644 packages/mtmcanvas/cordis.patch.yml create mode 100644 packages/mtmcanvas/package.json create mode 100644 packages/mtmcanvas/scripts/build.mjs create mode 100644 packages/mtmcanvas/scripts/pack-check.mjs create mode 100644 packages/mtmcanvas/scripts/verify-package.mjs create mode 100644 packages/mtmcanvas/src/client/CanvasView.tsx create mode 100644 packages/mtmcanvas/src/client/MtmCanvasAction.tsx create mode 100644 packages/mtmcanvas/src/client/index.ts create mode 100644 packages/mtmcanvas/src/client/runtime.test.ts create mode 100644 packages/mtmcanvas/src/client/runtime.ts create mode 100644 packages/mtmcanvas/src/client/styles.ts create mode 100644 packages/mtmcanvas/src/contract/canvas.test.ts create mode 100644 packages/mtmcanvas/src/contract/canvas.ts create mode 100644 packages/mtmcanvas/src/contract/rpc.ts create mode 100644 packages/mtmcanvas/src/index.test.ts create mode 100644 packages/mtmcanvas/src/index.ts create mode 100644 packages/mtmcanvas/tests/package-contract.test.ts create mode 100644 packages/mtmcanvas/tsconfig.json create mode 100644 packages/mtmcanvas/vitest.config.ts diff --git a/packages/mtmcanvas/LICENSE b/packages/mtmcanvas/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/packages/mtmcanvas/README.md b/packages/mtmcanvas/README.md new file mode 100644 index 0000000..816e5fd --- /dev/null +++ b/packages/mtmcanvas/README.md @@ -0,0 +1,34 @@ +# mtmcanvas + +Experimental independent DSH Web Canvas plugin extracted from the Canvas domain currently hosted by `mtmharness`. + +The package publishes two DSH faces: + +- Host half: `lib/index.js`, which mounts the existing file-backed Canvas RPC through DSH's abstract `ctx.fs` service. +- Browser half: `lib/client.cjs`, a `window.__ModuleLoader__.load` bundle that registers the Canvas sidebar action. + +This package is a dynamic-loading experiment. The existing Canvas implementation remains in `mtmharness` during the experiment so the published main package stays compatible; remove that duplicate only after the independent profile path is accepted. + +ponytail: temporary source duplication keeps the experiment reversible; delete the integrated Canvas copy after the independent package path is validated. + +## Install Into DSH Web + +```bash +dsh plugin --profile web add mtmcanvas +dsh --profile web --dump-config + +# Restart the Web profile after changing Bundle membership. +``` + +The package patch inserts the `mtmcanvas` Loader row. The official Web client module table discovers the `dsh.client` declaration and serves the exported `./client` bundle. + +The separate `standalone/` client in `mtmharness` is not part of this plugin. It is the cloud multi-user client and does not receive the file-backed Host implementation. + +## Development + +```bash +pnpm --filter mtmcanvas run check +pnpm --filter mtmcanvas run pack:check +``` + +The browser artifact uses DSH's React singleton and dynamic module table. It does not create a React root, router, authentication flow, or local filesystem. `ctx.fs` is the Host capability boundary and can be backed by a remote implementation later. diff --git a/packages/mtmcanvas/cordis.patch.yml b/packages/mtmcanvas/cordis.patch.yml new file mode 100644 index 0000000..69b6206 --- /dev/null +++ b/packages/mtmcanvas/cordis.patch.yml @@ -0,0 +1,4 @@ +# Installable profile layer for the experimental mtmcanvas Host/Client plugin. +- insert: + - id: mtmcanvas + name: mtmcanvas diff --git a/packages/mtmcanvas/package.json b/packages/mtmcanvas/package.json new file mode 100644 index 0000000..8e45825 --- /dev/null +++ b/packages/mtmcanvas/package.json @@ -0,0 +1,99 @@ +{ + "name": "mtmcanvas", + "version": "0.1.0", + "description": "Experimental Canvas DSH Web plugin extracted from mtmharness.", + "type": "module", + "engines": { + "node": ">=22.19.0", + "pnpm": ">=11.7.0" + }, + "main": "./lib/index.js", + "types": "./lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.cjs" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./package.json": "./package.json" + }, + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-primitives", + "@deepseek-ai/dsh-client-ui-sidebar", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-slots" + ] + } + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/client.cjs", + "lib/types/**/*.d.ts", + "cordis.patch.yml", + "README.md", + "package.json", + "LICENSE" + ], + "scripts": { + "build": "node scripts/build.mjs", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "verify:package": "node scripts/verify-package.mjs", + "check": "pnpm run typecheck && pnpm run test && pnpm run build && pnpm run verify:package", + "pack:check": "node scripts/pack-check.mjs", + "prepack": "pnpm run build && pnpm run verify:package" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codeh007/mtmdsh.git", + "directory": "packages/mtmcanvas" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2", + "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2", + "@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2", + "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.1-rc.2", + "@deepseek-ai/dsh-client-ui-layout": "^0.1.1-rc.2", + "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2", + "@deepseek-ai/dsh-fs": "^0.1.1-rc.2", + "@deepseek-ai/dsh-host-directory-picker": "^0.1.1-rc.2", + "react": ">=18.2.0 <20" + }, + "devDependencies": { + "@deepseek-ai/cordis": "4.0.1", + "@deepseek-ai/dsh-client-connection": "0.1.1-rc.2", + "@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2", + "@deepseek-ai/dsh-client-ui-primitives": "0.1.1-rc.2", + "@deepseek-ai/dsh-client-ui-sidebar": "0.1.1-rc.2", + "@deepseek-ai/dsh-client-ui-layout": "0.1.1-rc.2", + "@deepseek-ai/dsh-client-ui-slots": "0.1.1-rc.2", + "@deepseek-ai/dsh-fs": "0.1.1-rc.2", + "@deepseek-ai/dsh-host-directory-picker": "0.1.1-rc.2", + "@types/node": "22.20.1", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", + "esbuild": "0.28.2", + "react": "18.3.1", + "react-dom": "18.3.1", + "typescript": "6.0.3", + "vitest": "4.1.11" + } +} diff --git a/packages/mtmcanvas/scripts/build.mjs b/packages/mtmcanvas/scripts/build.mjs new file mode 100644 index 0000000..a9fdddd --- /dev/null +++ b/packages/mtmcanvas/scripts/build.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } 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"); +const clientTemp = resolve(libRoot, "client.bundle.cjs"); +const packageName = "mtmcanvas"; + +rmSync(libRoot, { recursive: true, force: true }); +mkdirSync(libRoot, { recursive: true }); +if (!existsSync(tsc)) throw new Error("mtmcanvas build: local TypeScript executable is missing"); +execFileSync(tsc, ["--project", resolve(packageRoot, "tsconfig.json")], { cwd: packageRoot, stdio: "inherit" }); + +await build({ + entryPoints: [resolve(packageRoot, "src/index.ts")], + outfile: resolve(libRoot, "index.js"), + bundle: true, + format: "esm", + platform: "node", + packages: "external", + target: "es2022", + logLevel: "info", +}); + +await build({ + entryPoints: [resolve(packageRoot, "src/client/index.ts")], + outfile: clientTemp, + bundle: true, + format: "cjs", + platform: "browser", + target: "es2020", + external: ["react", "react/*", "@deepseek-ai/*"], + legalComments: "none", + logLevel: "info", +}); + +const clientSource = readFileSync(clientTemp, "utf8"); +const indented = clientSource.split("\n").map((line) => " " + line).join("\n"); +const artifact = [ + "window.__ModuleLoader__.load({", + " id: " + JSON.stringify(packageName) + ",", + " factory: (require) => {", + " var module = { exports: {} };", + " var exports = module.exports;", + indented, + " return module.exports;", + " }", + "});", + "", +].join("\n"); +if (!artifact.includes("window.__ModuleLoader__.load") || !artifact.includes("id: \"" + packageName + "\"")) { + throw new Error("mtmcanvas build: generated client artifact does not have the DSH loader contract"); +} +writeFileSync(resolve(libRoot, "client.js"), artifact); +writeFileSync(resolve(libRoot, "client.cjs"), artifact); +rmSync(clientTemp, { force: true }); + +console.log("built mtmcanvas Host and Client artifacts"); diff --git a/packages/mtmcanvas/scripts/pack-check.mjs b/packages/mtmcanvas/scripts/pack-check.mjs new file mode 100644 index 0000000..2142cda --- /dev/null +++ b/packages/mtmcanvas/scripts/pack-check.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const destination = mkdtempSync(join(tmpdir(), "mtmcanvas-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("mtmcanvas pack: pnpm did not create a tarball"); + execFileSync(process.execPath, [join(packageRoot, "scripts/verify-package.mjs"), join(destination, tarball)], { cwd: packageRoot, stdio: "inherit" }); +} finally { + rmSync(destination, { recursive: true, force: true }); +} diff --git a/packages/mtmcanvas/scripts/verify-package.mjs b/packages/mtmcanvas/scripts/verify-package.mjs new file mode 100644 index 0000000..e5b9a0e --- /dev/null +++ b/packages/mtmcanvas/scripts/verify-package.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")); +const fail = (message) => { throw new Error("mtmcanvas package: " + message); }; + +if (manifest.private === true) fail("package must be publishable"); +if (manifest.name !== "mtmcanvas") fail("unexpected package name"); +if (!/^\d+\.\d+\.\d+$/.test(manifest.version)) fail("version must be stable SemVer"); +if (manifest.dsh?.bundle?.patch !== "./cordis.patch.yml") fail("dsh.bundle.patch must point to cordis.patch.yml"); +if (manifest.dsh?.client?.platform !== "web") fail("dsh.client.platform must be web"); +if (!Array.isArray(manifest.dsh?.client?.inject)) fail("dsh.client.inject must be an array"); +if (manifest.exports?.["."]?.default !== "./lib/index.js") fail("exports root must point to lib/index.js"); +if (manifest.exports?.["./client"]?.default !== "./lib/client.cjs") fail("exports ./client must point to lib/client.cjs"); +for (const path of ["cordis.patch.yml", "lib/index.js", "lib/client.js", "lib/client.cjs", "lib/types/index.d.ts", "lib/types/client/index.d.ts"]) { + if (!existsSync(resolve(packageRoot, path))) fail("missing build output " + path); +} +const patch = readFileSync(resolve(packageRoot, "cordis.patch.yml"), "utf8"); +if (!patch.includes("id: mtmcanvas") || !patch.includes("name: mtmcanvas")) fail("profile patch must insert the mtmcanvas Loader row"); +const host = readFileSync(resolve(packageRoot, "lib/index.js"), "utf8"); +for (const required of ["mtmcanvas", "mtm-canvas: file-backed RPC", "ctx.fs"]) { + if (!host.includes(required)) fail("Host artifact is missing " + required); +} +const client = readFileSync(resolve(packageRoot, "lib/client.js"), "utf8"); +for (const required of ["window.__ModuleLoader__.load", 'id: "mtmcanvas"', "sidebar.footer.action"]) { + if (!client.includes(required)) fail("Client artifact is missing " + required); +} + +const tarball = process.argv[2]; +if (tarball !== undefined) { + const entries = execFileSync("tar", ["-tzf", resolve(tarball)], { encoding: "utf8" }) + .split("\n") + .filter((entry) => entry.startsWith("package/") && !entry.endsWith("/")); + for (const entry of [ + "package/LICENSE", + "package/README.md", + "package/cordis.patch.yml", + "package/lib/index.js", + "package/lib/client.js", + "package/lib/client.cjs", + "package/lib/types/index.d.ts", + "package/lib/types/client/index.d.ts", + "package/package.json", + ]) { + if (!entries.includes(entry)) fail("tarball is missing " + entry); + } + if (entries.some((entry) => entry.includes(".test."))) fail("tarball must not contain test declarations"); +} + +console.log("verified mtmcanvas@" + manifest.version + (tarball === undefined ? "" : " tarball")); diff --git a/packages/mtmcanvas/src/client/CanvasView.tsx b/packages/mtmcanvas/src/client/CanvasView.tsx new file mode 100644 index 0000000..53da793 --- /dev/null +++ b/packages/mtmcanvas/src/client/CanvasView.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState, type DragEvent, type ReactElement } from "react"; +import type { CanvasNode } from "../contract/canvas.ts"; +import type { CanvasActions, CanvasViewState } from "./runtime.ts"; + +function NodeCard({ node, selected, onSelect, onDragEnd }: { node: CanvasNode; selected: boolean; onSelect: () => void; onDragEnd: (event: DragEvent) => void }): ReactElement { + return ( +
+ + {node.kind === "prompt" ?

{node.prompt || "Empty prompt"}

:
Image output
} +
+ ); +} + +export function CanvasView({ state, actions }: { state: CanvasViewState; actions: CanvasActions }): ReactElement { + const stageRef = useRef(null); + const [selectedId, setSelectedId] = useState(); + const [draft, setDraft] = useState(""); + const [filename, setFilename] = useState("untitled.canvas"); + const document = state.document; + const selected = document?.nodes.find((node) => node.id === selectedId); + + useEffect(() => { + if (selected?.kind === "prompt") setDraft(selected.prompt); + }, [selected]); + + function create(event: React.FormEvent): void { + event.preventDefault(); + const name = filename.endsWith(".canvas") ? filename : filename + ".canvas"; + actions.create(name); + } + + function moveNode(node: CanvasNode, event: DragEvent): void { + const rect = stageRef.current?.getBoundingClientRect(); + const viewport = document?.viewport; + if (rect === undefined || viewport === undefined) return; + actions.moveNode(node.id, { + x: Math.max(0, (event.clientX - rect.left - viewport.x) / viewport.k - node.size.width / 2), + y: Math.max(0, (event.clientY - rect.top - viewport.y) / viewport.k - 24 / viewport.k), + }); + } + + function zoom(delta: number): void { + if (document === undefined) return; + actions.setViewport({ ...document.viewport, k: Math.min(5, Math.max(0.05, document.viewport.k + delta)) }); + } + + return ( +
+
+
Canvas{state.name ?? "No file selected"}
+
+ + {document === undefined ? "--" : Math.round(document.viewport.k * 100) + "%"} + + +
+
+
+ +
+ {document === undefined ? Create or open a canvas file. : ( +
+ {document.connections.map((connection) => { + const from = document.nodes.find((node) => node.id === connection.fromNodeId); + const to = document.nodes.find((node) => node.id === connection.toNodeId); + if (from === undefined || to === undefined) return null; + const width = to.position.x - from.position.x - from.size.width; + if (width <= 0) return null; + return
; + })} + {document.nodes.map((node) => setSelectedId(node.id)} onDragEnd={(event) => moveNode(node, event)} />)} +
+ )} +
+