From 17dba2a34c1dbd908be9f8e3f73d3cd8b20811c8 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 3 Oct 2026 21:07:07 +0200 Subject: [PATCH] Beside every TypeScript entrypoint the builder writes an executable launcher; the runtime is told it (hq ADR 0193) The runtime knows no language: the build makes each served entrypoint executable. For a TypeScript bundle that is .serve.mjs, which imports the entrypoint and serves what it registered over MCP on stdio through the bundle's own SDK. The build records its launchers on the bundle, and the composer names the launcher where a build wrote one and the entrypoint where it did not, so bundles built before this keep serving until they are rebuilt. --- internal/builder/builder.go | 50 +++++++++++++++++++++++++++++- internal/builder/launchers_test.go | 49 +++++++++++++++++++++++++++++ internal/catalogue/build.go | 5 ++- internal/catalogue/manifest.go | 3 ++ internal/catalogue/runtime.go | 5 +++ internal/catalogue/runtime_test.go | 26 ++++++++++++++++ 6 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 internal/builder/launchers_test.go diff --git a/internal/builder/builder.go b/internal/builder/builder.go index fdcbb64..4fdfc42 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -587,6 +587,12 @@ func one(ctx context.Context, run Runner, publish Publisher, if err != nil { return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err) } + // **Every entrypoint the runtime may serve is executable** (novox/hq ADR 0193). The runtime + // knows no language; for one that runs through an interpreter the build writes the launcher. + launchers, err := writeLaunchers(compiled, chain, a) + if err != nil { + return catalogue.Built{}, fmt.Errorf("%s: writing %s's launchers failed: %w", module, a.Name, err) + } say("bundle", "compiled, packing") body, err := pack(compiled) if err != nil { @@ -598,7 +604,7 @@ func one(ctx context.Context, run Runner, publish Publisher, if err != nil { return catalogue.Built{}, err } - return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest}, nil + return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest, Launchers: launchers}, nil case catalogue.ArtifactPackage: // Built and published on a public base, to the mesh's package registry, by version @@ -1173,3 +1179,45 @@ func binaryName(a catalogue.Artifact) string { } return a.Name } + +// launcherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is +// started as index.serve.mjs (novox/hq ADR 0193). An ES module by its own extension, whatever the +// bundle's package.json says. +const launcherSuffix = ".serve.mjs" + +// writeLaunchers writes, beside every entrypoint of a TypeScript bundle, an executable that +// imports the entrypoint and serves what it registered over MCP on stdio — through the bundle's +// own copy of the SDK, so registering and serving meet in one registry (novox/hq ADR 0193). Its +// answer is each entrypoint's launcher, by entrypoint, relative to the bundle's root; nothing for +// a language whose build is already executable. +func writeLaunchers(root string, chain Toolchain, a catalogue.Artifact) (map[string]string, error) { + if chain.Language != "typescript" { + return nil, nil + } + out := map[string]string{} + for _, entry := range a.Entrypoints { + if !strings.HasSuffix(entry, ".js") { + continue + } + launcher := strings.TrimSuffix(entry, ".js") + launcherSuffix + body := "#!/usr/bin/env node\n" + + "// Written by the mesh's builder (novox/hq ADR 0193): serve what " + entry + " registers,\n" + + "// over MCP on stdio, as the module the node's runtime names in MESH_SERVED_MODULE.\n" + + "import { serveRegisteredOverStdio } from \"@novox/mesh-sdk/stdio\";\n" + + "await import(\"./" + filepath.Base(entry) + "\");\n" + + "await serveRegisteredOverStdio();\n" + path := filepath.Join(root, filepath.FromSlash(launcher)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + return nil, err + } + // WriteFile honours the umask; the mode a machine unpacks is the one packed, so it is set. + if err := os.Chmod(path, 0o755); err != nil { + return nil, err + } + out[entry] = launcher + } + return out, nil +} diff --git a/internal/builder/launchers_test.go b/internal/builder/launchers_test.go new file mode 100644 index 0000000..8185eff --- /dev/null +++ b/internal/builder/launchers_test.go @@ -0,0 +1,49 @@ +package builder + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// novox/hq ADR 0193: every entrypoint the runtime may serve is executable, and the runtime knows no +// language — so a TypeScript bundle carries a launcher beside each entrypoint. +func TestATypeScriptBundleCarriesAnExecutableLauncherBesideEachEntrypoint(t *testing.T) { + root := t.TempDir() + chain, err := ToolchainFor("typescript") + if err != nil { + t.Fatal(err) + } + got, err := writeLaunchers(root, chain, catalogue.Artifact{Name: "tools", Kind: catalogue.ArtifactBundle, + Language: "typescript", Entrypoints: []string{"tools/index.js", "index.js"}}) + if err != nil { + t.Fatal(err) + } + if got["tools/index.js"] != "tools/index.serve.mjs" || got["index.js"] != "index.serve.mjs" { + t.Fatalf("launchers: %v", got) + } + path := filepath.Join(root, "tools", "index.serve.mjs") + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o755 { + t.Errorf("the launcher is %v, not executable 0755", info.Mode().Perm()) + } + body, _ := os.ReadFile(path) + for _, want := range []string{"#!/usr/bin/env node\n", `from "@novox/mesh-sdk/stdio"`, `await import("./index.js")`, "serveRegisteredOverStdio()"} { + if !strings.Contains(string(body), want) { + t.Errorf("the launcher lacks %q:\n%s", want, body) + } + } + + // A compiled language's build is executable already: no launcher. + goChain, _ := ToolchainFor("go") + none, err := writeLaunchers(t.TempDir(), goChain, catalogue.Artifact{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "go"}) + if err != nil || len(none) != 0 { + t.Errorf("a Go bundle was given launchers: %v %v", none, err) + } +} diff --git a/internal/catalogue/build.go b/internal/catalogue/build.go index 68b70b8..88f77ab 100644 --- a/internal/catalogue/build.go +++ b/internal/catalogue/build.go @@ -28,6 +28,9 @@ type Built struct { Reference string // Digest is "sha256:", for an archive. An image reference already ends in one. Digest string + // Launchers are, for a bundle in an interpreted language, the executable the build wrote beside + // each entrypoint, by entrypoint (novox/hq ADR 0193): what the node's runtime starts to serve it. + Launchers map[string]string } // Resolve fills a manifest's resources in from what was built. @@ -92,7 +95,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) { out.Bundles = append(out.Bundles, Bundle{ Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest, Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...), - Loads: loads, Env: copyWords(a.Env), + Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers), }) } sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name }) diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 5233a19..8acd381 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -604,6 +604,9 @@ type Bundle struct { Loads []string `json:"loads,omitempty"` // Env is what the artifact said it is given (ADR 0192), as written; composed per machine. Env map[string]string `json:"env,omitempty"` + // Launchers are the executables the build wrote beside its entrypoints, by entrypoint (novox/hq + // ADR 0193). A bundle built before them has none, and is served as it was built. + Launchers map[string]string `json:"launchers,omitempty"` } // Build says how to produce this module's artifacts from its source. diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go index 0722e49..4a33cc7 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -169,6 +169,11 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) { continue } for _, load := range b.Loads { + // What the runtime starts: the launcher the build wrote beside the entrypoint, where + // it wrote one (ADR 0193); the entrypoint itself for a build from before them. + if launcher, has := b.Launchers[load]; has { + load = launcher + } served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load) } restartOn = append(restartOn, m.Module+"."+BundleID(b.Name)) diff --git a/internal/catalogue/runtime_test.go b/internal/catalogue/runtime_test.go index 4003fb2..0411124 100644 --- a/internal/catalogue/runtime_test.go +++ b/internal/catalogue/runtime_test.go @@ -336,3 +336,29 @@ func TestABundlesWordsAreRefusedWhenTheyAreNotPathsOrConstants(t *testing.T) { } } } + +// novox/hq ADR 0193: the runtime is told the launcher a build wrote, and the entrypoint itself for a +// build from before launchers — so the move needs no flag day. +func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) { + with := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}} + launched := Manifest{Module: "dash", Version: "1", Tools: []string{"status"}, + Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript", + Entrypoints: []string{"tools/index.js"}}}}} + launched, err := launched.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest, + Launchers: map[string]string{"tools/index.js": "tools/index.serve.mjs"}}}) + if err != nil { + t.Fatal(err) + } + older := aToolsModule(t, "nftables", "tools/index.js") + out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{launched, older, theRuntime(t)}}.Declaration(with) + if err != nil { + t.Fatal(err) + } + env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string) + want := "dash=" + BundleRoot + "/dash/tools/tools/index.serve.mjs,nftables=" + BundleRoot + "/nftables/tools/tools/index.js" + if env[RuntimeToolModules] != want { + t.Errorf("the runtime is told %q, want %q", env[RuntimeToolModules], want) + } +}