From 8d1a3a7a362162dd0837822d46e746b39b73e56d Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Thu, 4 Jun 2026 00:14:26 -0400 Subject: [PATCH 01/24] m-iris M0: rename from irissync + driver contract seam + remote spike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the irissync binary/module to m-iris and adopt the m engine-driver contract (driver-contract.md v1.0), test-first throughout. M0 (scaffold + SDK seam + meta): - module github.com/vista-cloud-dev/m-iris, binary m-iris, env M_IRIS_* - internal/driver: honest CapsDoc() (golden), ContractVersion, verb-level Transport seam (Exec/Load/ReadGlobal/SetGlobal/Health) + FakeTransport - clikit: contract exit ladder 0/2/3/4/5/6/7 + engineError envelope field (§7) - axis command tree `m-iris `: meta (caps/info/version/schema) + sync (the regrouped source verbs) Remote spike (risk B2 — the whole remote substrate): - m_iris.Runner.cls: role-gated, parameterized SqlProc runner; faults captured to ^mIrisRun(rid,"error") in §7 shape - atelier.Query: POST {ns}/action/query (parameter-bound SQL) - internal/remote.Transport (implements driver.Transport): lazy PUT+compile of the runner, Exec run/eval with fault→EngineError, data set/get, Health probing the action/query privilege - unit tier green every commit (fake AtelierAPI); TestRemoteSpike_RealEngine gated on M_IRIS_IT=1 + a provisioned IRIS CE container Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 4 +- clikit/context.go | 13 + clikit/errors.go | 32 ++- clikit/version.go | 2 +- commands.go | 18 +- deploy.go | 311 ++++++++++++++++++++++ deploy_test.go | 71 +++++ docs/m-iris-driver-status.md | 56 ++++ go.mod | 2 +- internal/atelier/doc.go | 16 ++ internal/atelier/query.go | 83 ++++++ internal/atelier/query_test.go | 72 +++++ internal/config/config.go | 30 +-- internal/driver/caps.go | 53 ++++ internal/driver/caps_test.go | 52 ++++ internal/driver/fake.go | 69 +++++ internal/driver/testdata/caps.golden.json | 31 +++ internal/driver/transport.go | 97 +++++++ internal/driver/transport_test.go | 60 +++++ internal/remote/integration_test.go | 98 +++++++ internal/remote/remote.go | 256 ++++++++++++++++++ internal/remote/remote_test.go | 142 ++++++++++ internal/remote/runner/m_iris.Runner.cls | 105 ++++++++ main.go | 71 +++-- main_test.go | 4 +- meta.go | 80 ++++++ meta_test.go | 63 +++++ push.go | 14 +- push_test.go | 4 +- 29 files changed, 1835 insertions(+), 74 deletions(-) create mode 100644 deploy.go create mode 100644 deploy_test.go create mode 100644 docs/m-iris-driver-status.md create mode 100644 internal/atelier/query.go create mode 100644 internal/atelier/query_test.go create mode 100644 internal/driver/caps.go create mode 100644 internal/driver/caps_test.go create mode 100644 internal/driver/fake.go create mode 100644 internal/driver/testdata/caps.golden.json create mode 100644 internal/driver/transport.go create mode 100644 internal/driver/transport_test.go create mode 100644 internal/remote/integration_test.go create mode 100644 internal/remote/remote.go create mode 100644 internal/remote/remote_test.go create mode 100644 internal/remote/runner/m_iris.Runner.cls create mode 100644 meta.go create mode 100644 meta_test.go diff --git a/Makefile b/Makefile index c4a96d9..5f236c6 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,8 @@ # -trimpath, version stamped via -ldflags, cross-compile matrix, lint, test, # schema. -BIN ?= irissync -PKG := github.com/vista-cloud-dev/irissync +BIN ?= m-iris +PKG := github.com/vista-cloud-dev/m-iris LDPKG := $(PKG)/clikit VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) diff --git a/clikit/context.go b/clikit/context.go index 58aa421..61a58cd 100644 --- a/clikit/context.go +++ b/clikit/context.go @@ -18,6 +18,19 @@ type Envelope struct { Data any `json:"data,omitempty"` Diagnostics []Diagnostic `json:"diagnostics,omitempty"` Error *Error `json:"error,omitempty"` + EngineError *EngineError `json:"engineError,omitempty"` +} + +// EngineError is the driver-contract §7 structured engine fault. On any +// compile/runtime fault, exec/cover verbs set ok=false AND surface this as a +// sibling of error, so a RED suite shows the real cause (a at a +// line) rather than passed:0, failed:0. Mnemonic carries the IRIS <…> / +// %YDB-E-… code. +type EngineError struct { + Routine string `json:"routine,omitempty"` + Line int `json:"line,omitempty"` + Mnemonic string `json:"mnemonic,omitempty"` + Text string `json:"text,omitempty"` } // Diagnostic is one lint/diagnostic finding (the editor↔CI shared shape). diff --git a/clikit/errors.go b/clikit/errors.go index 9425eb7..fc90ee3 100644 --- a/clikit/errors.go +++ b/clikit/errors.go @@ -5,13 +5,19 @@ import ( "fmt" ) -// Exit codes — the toolchain-wide ladder (spec §3.3). Every CLI uses these. +// Exit codes — the m engine-driver contract ladder (driver-contract.md §2). +// Every m-iris verb returns one of these; m-cli branches on the code. +// +// 0 ok · 2 usage · 3 gate/tests-failed · 4 conflict/refusal · +// 5 runtime · 6 engine-unreachable · 7 unsupported (verb/transport) const ( - ExitOK = 0 // success - ExitRuntime = 1 // runtime error (IO / engine / parse) - ExitUsage = 2 // usage error (bad flags/args) - ExitCheck = 3 // --check / lint found findings or drift - ExitRefused = 4 // engine-bound op refused (no engine / substrate unavailable) + ExitOK = 0 // success + ExitUsage = 2 // usage error (bad flags/args) + ExitCheck = 3 // gate: --check/lint findings, drift, or tests failed + ExitRefused = 4 // conflict / refusal (lock held, conflict-check, prune scope) + ExitRuntime = 5 // runtime error (IO / engine fault / parse) + ExitUnreachable = 6 // engine unreachable (no connectivity / auth) + ExitUnsupported = 7 // verb or transport not available on this engine — query caps first ) // Error is the deterministic, machine-parseable error object. Commands return @@ -21,6 +27,11 @@ type Error struct { Exit int `json:"exit"` Message string `json:"message"` Hint string `json:"hint,omitempty"` + + // Engine, when set, is surfaced at envelope.engineError (a sibling of + // error, never nested) — the §7 structured engine fault behind a failed + // exec/cover verb. + Engine *EngineError `json:"-"` } func (e *Error) Error() string { return e.Message } @@ -30,6 +41,13 @@ func Fail(exit int, code, message, hint string) *Error { return &Error{Code: code, Exit: exit, Message: message, Hint: hint} } +// FailEngine is Fail plus a §7 engine fault, surfaced at envelope.engineError. +// Use it for exec/cover faults so the real cause (routine, line, mnemonic) +// reaches m-cli alongside the deterministic error code. +func FailEngine(exit int, code, message, hint string, eng *EngineError) *Error { + return &Error{Code: code, Exit: exit, Message: message, Hint: hint, Engine: eng} +} + // exitOf maps any error to an exit code (clikit.Error keeps its own). func exitOf(err error) int { var e *Error @@ -47,7 +65,7 @@ func RenderError(c *Context, err error) { e = &Error{Code: "RUNTIME", Exit: ExitRuntime, Message: err.Error()} } if c.JSON() { - _ = writeJSON(c.Stderr, Envelope{SchemaVersion: SchemaVersion, Command: c.Command, OK: false, Exit: e.Exit, Error: e}) + _ = writeJSON(c.Stderr, Envelope{SchemaVersion: SchemaVersion, Command: c.Command, OK: false, Exit: e.Exit, Error: e, EngineError: e.Engine}) return } fmt.Fprintf(c.Stderr, "%s %s\n", c.th.err.render(c.Color, c.gl.Err+" Error:"), e.Message) diff --git a/clikit/version.go b/clikit/version.go index 1c1ad67..c7ef44a 100644 --- a/clikit/version.go +++ b/clikit/version.go @@ -4,7 +4,7 @@ import "runtime" // Build metadata, injected at link time: // -// go build -ldflags "-X github.com/vista-cloud-dev/irissync/clikit.Version=$VER \ +// go build -ldflags "-X github.com/vista-cloud-dev/m-iris/clikit.Version=$VER \ // -X …/clikit.Commit=$SHA -X …/clikit.Date=$DATE" var ( Version = "dev" diff --git a/commands.go b/commands.go index e0233b6..0ffc797 100644 --- a/commands.go +++ b/commands.go @@ -10,11 +10,11 @@ import ( "sync" "time" - "github.com/vista-cloud-dev/irissync/clikit" - "github.com/vista-cloud-dev/irissync/internal/atelier" - "github.com/vista-cloud-dev/irissync/internal/config" - "github.com/vista-cloud-dev/irissync/internal/manifest" - "github.com/vista-cloud-dev/irissync/internal/mirror" + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/atelier" + "github.com/vista-cloud-dev/m-iris/internal/config" + "github.com/vista-cloud-dev/m-iris/internal/manifest" + "github.com/vista-cloud-dev/m-iris/internal/mirror" ) // --- list -------------------------------------------------------------------- @@ -301,7 +301,7 @@ func (statusCmd) Run(cc *clikit.Context, conn *config.Conn) error { renderDiff(cc, d) }, d.Drift(), "DRIFT", fmt.Sprintf("%d new, %d changed, %d deleted — mirror out of sync", len(d.New), len(d.Changed), len(d.Deleted)), - "run 'irissync pull' to update the mirror") + "run 'm-iris sync pull' to update the mirror") } // --- verify ------------------------------------------------------------------ @@ -327,7 +327,7 @@ func (verifyCmd) Run(cc *clikit.Context, conn *config.Conn) error { } if man == nil { return clikit.Fail(clikit.ExitRuntime, "NO_MANIFEST", - "no manifest at "+layout.ManifestPath()+"; run 'irissync pull' first", "") + "no manifest at "+layout.ManifestPath()+"; run 'm-iris sync pull' first", "") } names, err := scopeManifest(man, conn.Filter, conn.Package) @@ -368,7 +368,7 @@ func (verifyCmd) Run(cc *clikit.Context, conn *config.Conn) error { } }, drift, "MISMATCH", fmt.Sprintf("%d mismatched, %d missing — mirror does not match the manifest", len(mismatch), len(missing)), - "re-run 'irissync pull' or investigate tampering") + "re-run 'm-iris sync pull' or investigate tampering") } // --- shared helpers ---------------------------------------------------------- @@ -399,7 +399,7 @@ func runtimeErr(err error) error { } func usageErr(err error) error { - return clikit.Fail(clikit.ExitUsage, "BAD_CONFIG", err.Error(), "set flags or IRISSYNC_* env vars") + return clikit.Fail(clikit.ExitUsage, "BAD_CONFIG", err.Error(), "set flags or M_IRIS_* env vars") } // selectDocs filters a docnames listing by package prefix and glob filter. diff --git a/deploy.go b/deploy.go new file mode 100644 index 0000000..45411a3 --- /dev/null +++ b/deploy.go @@ -0,0 +1,311 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/atelier" + "github.com/vista-cloud-dev/m-iris/internal/config" +) + +// deployCmd installs a library of routine source (e.g. m-stdlib/src) into an IRIS +// namespace over the official Atelier REST API — PUT each routine, then compile. +// Unlike push (which writes back an edited *mirror* under a conflict-check), deploy +// is a one-way install of a source tree: it has no manifest basis, so it always +// overwrites the namespace copy with the source on disk. That is the intended +// semantic for shipping a versioned library — re-running deploy upgrades in place. +// +// --prune makes it a true sync: routines on the server that share the deployed +// set's common name prefix but are absent from the source are deleted (e.g. a +// module dropped between releases). A safety guard refuses to prune unless the +// deployed set has a coherent common prefix of at least minPrunePrefix chars, so +// the prune scope can never widen to unrelated routines (e.g. VistA's). +type deployCmd struct { + Paths []string `arg:"" optional:"" type:"path" help:"Source dirs or .m files to install (default: src)."` + NoCompile bool `name:"no-compile" help:"Skip the post-import compile (compile is on by default)."` + Prune bool `help:"Delete server routines in the deployed set's name-prefix that are absent from the source (true sync)."` +} + +// minPrunePrefix is the shortest common routine-name prefix --prune will act on. +// "STD" (m-stdlib) is exactly 3; anything shorter is too broad to delete safely. +const minPrunePrefix = 3 + +type deployItem struct { + Routine string `json:"routine"` + Status string `json:"status"` // installed | compile-error | pruned + Detail string `json:"detail,omitempty"` +} + +type deployResult struct { + Namespace string `json:"namespace"` + Installed int `json:"installed"` + Pruned int `json:"pruned"` + CompileError int `json:"compileErrors"` + Compiled bool `json:"compiled"` + PrunePrefix string `json:"prunePrefix,omitempty"` + DryRun bool `json:"dryRun,omitempty"` + Items []deployItem `json:"items"` +} + +func (c *deployCmd) Run(cc *clikit.Context, conn *config.Conn) error { + if err := conn.Validate(config.Need{Network: true}); err != nil { + return usageErr(err) + } + + paths := c.Paths + if len(paths) == 0 { + paths = []string{"src"} + } + files, err := collectMFiles(paths) + if err != nil { + return usageErr(err) + } + + acfg, err := conn.Atelier() + if err != nil { + return usageErr(err) + } + client, err := atelier.New(acfg) + if err != nil { + return runtimeErr(err) + } + ctx := context.Background() + + // Map docname → file, and keep a sorted docname list for deterministic order. + docToFile := map[string]string{} + var docnames []string + for _, f := range files { + d := deployDocname(f) + docToFile[d] = f + docnames = append(docnames, d) + } + sort.Strings(docnames) + + res := deployResult{Namespace: conn.Namespace, DryRun: conn.DryRun} + + // Plan prune up front (a read) so a bad --prune scope fails before any write. + var orphans []string + if c.Prune { + serverDocs, err := client.DocNames(ctx, conn.Type, "") + if err != nil { + return runtimeErr(err) + } + names := make([]string, 0, len(serverDocs)) + for _, d := range serverDocs { + names = append(names, d.Name) + } + var prefix string + orphans, prefix, err = prunePlan(docnames, names) + if err != nil { + return clikit.Fail(clikit.ExitUsage, "PRUNE_SCOPE", + err.Error(), "deploy a prefix-coherent set (e.g. all STD*), or drop --prune") + } + res.PrunePrefix = prefix + } + + if conn.DryRun { + for _, d := range docnames { + res.Items = append(res.Items, deployItem{Routine: d, Status: "to-install"}) + } + for _, d := range orphans { + res.Items = append(res.Items, deployItem{Routine: d, Status: "to-prune"}) + } + res.Installed, res.Pruned = len(docnames), len(orphans) + return c.emit(cc, res, false) + } + + // Install: PUT each routine's source, then compile the whole set once. + for _, d := range docnames { + lines, err := readLines(docToFile[d]) + if err != nil { + return runtimeErr(fmt.Errorf("read %s: %w", docToFile[d], err)) + } + if _, err := client.PutDoc(ctx, d, lines); err != nil { + return runtimeErr(fmt.Errorf("PUT %s: %w", d, err)) + } + res.Items = append(res.Items, deployItem{Routine: d, Status: "installed"}) + res.Installed++ + } + + compileFailed := false + if !c.NoCompile && len(docnames) > 0 { + res.Compiled = true + comp, err := client.Compile(ctx, docnames, "cuk") + if err != nil { + return runtimeErr(fmt.Errorf("compile: %w", err)) + } + if !comp.OK() { + compileFailed = true + for _, d := range comp.Diagnostics { + res.Items = append(res.Items, deployItem{Status: "compile-error", Detail: d}) + res.CompileError++ + } + } + } + + // Prune after a clean install: delete orphaned routines no longer shipped. + for _, d := range orphans { + if err := client.DeleteDoc(ctx, d); err != nil { + return runtimeErr(fmt.Errorf("prune %s: %w", d, err)) + } + res.Items = append(res.Items, deployItem{Routine: d, Status: "pruned"}) + res.Pruned++ + } + + return c.emit(cc, res, compileFailed) +} + +func (c *deployCmd) emit(cc *clikit.Context, res deployResult, compileFailed bool) error { + textFn := func() { + title := res.Namespace + " — deploy" + if res.DryRun { + title += " plan (dry run)" + } + cc.Title(title) + cc.KV( + [2]string{"installed", fmt.Sprint(res.Installed)}, + [2]string{"pruned", fmt.Sprint(res.Pruned)}, + [2]string{"compile errors", fmt.Sprint(res.CompileError)}, + [2]string{"namespace", cc.Accent(res.Namespace)}, + ) + for _, it := range res.Items { + if it.Status == "compile-error" { + fmt.Fprintln(cc.Stdout, cc.Warning("compile "+it.Detail)) + } + } + if !compileFailed && !res.DryRun { + fmt.Fprintln(cc.Stdout, cc.Success(fmt.Sprintf("installed %d routine(s)%s", res.Installed, prunedSuffix(res.Pruned)))) + } + } + if err := cc.Result(res, textFn); err != nil { + return err + } + if compileFailed { + return clikit.Fail(clikit.ExitCheck, "COMPILE_ERROR", + fmt.Sprintf("%d compile diagnostic(s) — source was installed but did not compile cleanly", res.CompileError), + "fix the routine source and deploy again") + } + return nil +} + +func prunedSuffix(n int) string { + if n == 0 { + return "" + } + return fmt.Sprintf(", pruned %d", n) +} + +// deployDocname maps a source file path to its IRIS routine docname: the base +// name, sans extension, upper-cased, with a ".mac" suffix (routines are MAC +// source). e.g. "../m-stdlib/src/STDJSON.m" → "STDJSON.mac". +func deployDocname(path string) string { + base := filepath.Base(path) + stem := strings.TrimSuffix(base, filepath.Ext(base)) + return strings.ToUpper(stem) + ".mac" +} + +// commonStemPrefix returns the longest common prefix of the upper-cased routine +// stems (a "stem" is a docname without its .mac extension). "" when the stems +// share nothing — which --prune treats as too broad to act on. +func commonStemPrefix(stems []string) string { + if len(stems) == 0 { + return "" + } + prefix := strings.ToUpper(stems[0]) + for _, s := range stems[1:] { + s = strings.ToUpper(s) + n := 0 + for n < len(prefix) && n < len(s) && prefix[n] == s[n] { + n++ + } + prefix = prefix[:n] + if prefix == "" { + break + } + } + return prefix +} + +// prunePlan computes which server routines to delete for a true sync: those in +// the deployed set's common name prefix but absent from the deployed set. It +// refuses (error) unless that common prefix is at least minPrunePrefix chars, so +// the delete scope can never widen to unrelated routines. Returned orphans are +// the server's verbatim docnames (so the DELETE targets exactly what it listed). +func prunePlan(deployedDocnames, serverDocnames []string) (orphans []string, prefix string, err error) { + deployedStems := make([]string, 0, len(deployedDocnames)) + deployedSet := map[string]bool{} + for _, d := range deployedDocnames { + stem := docStem(d) + deployedStems = append(deployedStems, stem) + deployedSet[stem] = true + } + prefix = commonStemPrefix(deployedStems) + if len(prefix) < minPrunePrefix { + return nil, prefix, fmt.Errorf("prune refused: deployed routines share no common name prefix of at least %d chars (got %q)", minPrunePrefix, prefix) + } + for _, sd := range serverDocnames { + stem := docStem(sd) + if strings.HasPrefix(stem, prefix) && !deployedSet[stem] { + orphans = append(orphans, sd) + } + } + sort.Strings(orphans) + return orphans, prefix, nil +} + +// docStem upper-cases a docname and strips its extension for comparison. +func docStem(docname string) string { + return strings.ToUpper(strings.TrimSuffix(docname, filepath.Ext(docname))) +} + +// collectMFiles expands paths (dirs → their *.m, .m files passed through) into a +// sorted, de-duplicated list of routine source files. +func collectMFiles(paths []string) ([]string, error) { + seen := map[string]bool{} + var out []string + for _, p := range paths { + info, err := os.Stat(p) + if err != nil { + return nil, err + } + if info.IsDir() { + ms, err := filepath.Glob(filepath.Join(p, "*.m")) + if err != nil { + return nil, err + } + for _, m := range ms { + if !seen[m] { + seen[m] = true + out = append(out, m) + } + } + } else if strings.HasSuffix(p, ".m") && !seen[p] { + seen[p] = true + out = append(out, p) + } + } + if len(out) == 0 { + return nil, fmt.Errorf("no .m routines found in %s", strings.Join(paths, ", ")) + } + sort.Strings(out) + return out, nil +} + +// readLines reads a routine source file as the line array Atelier PUT expects +// (a trailing newline is not emitted as a spurious empty final line). +func readLines(path string) ([]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + body := strings.TrimRight(string(data), "\n") + if body == "" { + return []string{}, nil + } + return strings.Split(body, "\n"), nil +} diff --git a/deploy_test.go b/deploy_test.go new file mode 100644 index 0000000..8b7f999 --- /dev/null +++ b/deploy_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "strings" + "testing" +) + +func TestDeployDocname(t *testing.T) { + cases := map[string]string{ + "src/STDJSON.m": "STDJSON.mac", + "../m-stdlib/src/STDB64.m": "STDB64.mac", + "stdregex.m": "STDREGEX.mac", // routine names are upper-cased + "/abs/path/STDCSV.m": "STDCSV.mac", + } + for in, want := range cases { + if got := deployDocname(in); got != want { + t.Errorf("deployDocname(%q) = %q, want %q", in, got, want) + } + } +} + +func TestCommonStemPrefix(t *testing.T) { + cases := []struct { + stems []string + want string + }{ + {[]string{"STDJSON", "STDB64", "STDASSERT"}, "STD"}, + {[]string{"STDJSON"}, "STDJSON"}, + {[]string{"STDARGS", "STDASSERT"}, "STDA"}, + {[]string{"STDJSON", "DGREG"}, ""}, // no shared prefix + {nil, ""}, + } + for _, tc := range cases { + if got := commonStemPrefix(tc.stems); got != tc.want { + t.Errorf("commonStemPrefix(%v) = %q, want %q", tc.stems, got, tc.want) + } + } +} + +func TestPrunePlan(t *testing.T) { + deployed := []string{"STDJSON.mac", "STDB64.mac"} + server := []string{ + "STDJSON.mac", // deployed → keep + "STDB64.mac", // deployed → keep + "STDOLD.mac", // STD-prefixed orphan → prune + "DGREG.mac", // VistA routine, not in prune scope → never touched + "XUSER.mac", // VistA routine → never touched + } + orphans, prefix, err := prunePlan(deployed, server) + if err != nil { + t.Fatalf("prunePlan err = %v", err) + } + if prefix != "STD" { + t.Errorf("prefix = %q, want STD", prefix) + } + if len(orphans) != 1 || orphans[0] != "STDOLD.mac" { + t.Fatalf("orphans = %v, want [STDOLD.mac]", orphans) + } +} + +func TestPrunePlanRefusesAmbiguousScope(t *testing.T) { + // A deploy set with no coherent common prefix must NOT be allowed to prune — + // otherwise the scope could widen to unrelated routines (e.g. VistA's). + _, _, err := prunePlan([]string{"STDJSON.mac", "DGREG.mac"}, []string{"STDJSON.mac", "ANY.mac"}) + if err == nil { + t.Fatal("expected prunePlan to refuse an ambiguous (too-short) prune prefix") + } + if !strings.Contains(strings.ToLower(err.Error()), "prefix") { + t.Errorf("error should explain the prefix guard, got: %v", err) + } +} diff --git a/docs/m-iris-driver-status.md b/docs/m-iris-driver-status.md new file mode 100644 index 0000000..0c1c336 --- /dev/null +++ b/docs/m-iris-driver-status.md @@ -0,0 +1,56 @@ +# m-iris driver — milestone status + +Tracking the [driver-implementation-plan §5](../../docs/m-engine-drivers/driver-implementation-plan.md) +table inside this repo (the plan doc itself is shared read-only source of truth). + +Legend: ☑ done · ◐ in progress · ☐ not started + +## M0 — scaffold + SDK seam + meta ☑ + +- ☑ Rename `irissync` → `m-iris`: module `github.com/vista-cloud-dev/m-iris`, + binary `m-iris`, env prefix `IRISSYNC_*` → `M_IRIS_*`. (Directory kept as + `irissync/`; git remote unchanged.) +- ☑ Contract types vendored thin in `internal/driver` (caps, `ContractVersion`, + the verb-level `Transport` seam, `clikit.EngineError` + envelope field). +- ☑ clikit exit ladder aligned to the contract: `0/2/3/4/5/6/7` + (added `ExitRuntime=5`, `ExitUnreachable=6`, `ExitUnsupported=7`). +- ☑ Axis command tree `m-iris `: `meta` (caps/info/version/schema) + + `sync` (the regrouped source verbs). `caps` golden-tested and **honest** + (advertises only what is wired; grows per milestone). + +## Remote spike (plan §5 task 8) — substrate built, real-engine green gated ◐ + +The remote substrate is the whole-cloth de-risking item (risk B2): Atelier has no +"run ObjectScript" endpoint, so all remote exec/data/cover/admin ride a SQL +runner class. Built and **unit-proven**; real-engine green runs in CI. + +- ☑ `m_iris.Runner` class (`internal/remote/runner/m_iris.Runner.cls`): + role-gated, parameterized SqlProc methods `RunRef`/`Eval`/`GetGlobal`/ + `SetGlobal`/`KillGlobal`/`Ping`; faults captured to `^mIrisRun(rid,"error")` + in §7 shape. +- ☑ `atelier.Query` — `POST {ns}/action/query` (SQL), parameter-bound. +- ☑ `internal/remote.Transport` implements `driver.Transport` over the runner: + lazy PUT+compile deploy, `Exec` (run/eval) with fault→`EngineError`, data + set/get, `Health` probes the action/query privilege (SELECT 1, not just TCP). +- ☑ Unit tier (fake `AtelierAPI`, runs every commit): deploy-once, clean run, + fault→EngineError, data round-trip, health. +- ◐ Real-engine tier: `TestRemoteSpike_RealEngine`, gated on `M_IRIS_IT=1` + + `M_IRIS_*` env. **Not yet run green** — needs a provisioned IRIS CE container + in CI (the shared dev `vista-iris` is off-limits). + +### Spike assumptions to confirm on the real engine +- SqlProc projection name is `m_iris.` (schema = package `m_iris`). +- `%Exception.Location` parses as `label+offset^routine` for the §7 frame. +- `do @ref` / `set @ref=value` name-indirection over a global reference string. + +## Next +- M1 lifecycle + health probes + `doctor`; wire the `local`/`docker` (`iris + session`) Transport strategies alongside `remote`. +- Phase-0 SDK reconciliation with m-ydb (see below). + +## SDK reconciliation note (Phase-0) +m-ydb drafted the seam first (`internal/transport`, separate `internal/contract` +pkg, `ExecMode` enum, flat `GlobalResult`, **no** `SetGlobal`). m-iris's +contribution: the Atelier-SQL fit (results via a global, not stdout) and the +**write** verbs (`SetGlobal`/kill) the data axis needs. Freeze + extract +`m-driver-sdk` against both shapes before building broad M3 work. diff --git a/go.mod b/go.mod index 9da8f7c..f81eac5 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/vista-cloud-dev/irissync +module github.com/vista-cloud-dev/m-iris go 1.26.3 diff --git a/internal/atelier/doc.go b/internal/atelier/doc.go index 7e6ba96..2122f52 100644 --- a/internal/atelier/doc.go +++ b/internal/atelier/doc.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "strings" ) @@ -82,6 +83,21 @@ func (c *Client) PutDoc(ctx context.Context, name string, content []string) (*Pu return res, nil } +// DeleteDoc removes a document from the server (DELETE …/doc/{name}). It is used +// by `irissync deploy --prune` to drop routines no longer in the source set. A +// document that is already absent is treated as success (the desired end state). +func (c *Client) DeleteDoc(ctx context.Context, name string) error { + u := c.endpoint(c.namespace, "doc", name) + var env Envelope + if err := c.do(ctx, http.MethodDelete, u, nil, &env); err != nil { + if isNotFound(err) { + return nil + } + return err + } + return nil +} + // Stat fetches a document's current metadata (timestamp) without committing to // keep its body. It is used by push's conflict-check to read the live server // state just before a write. A missing document returns ok=false and no error. diff --git a/internal/atelier/query.go b/internal/atelier/query.go new file mode 100644 index 0000000..85bd7b5 --- /dev/null +++ b/internal/atelier/query.go @@ -0,0 +1,83 @@ +package atelier + +import ( + "context" + "encoding/json" + "fmt" +) + +// Query runs a SQL statement via POST {namespace}/action/query and returns the +// result rows (result.content), each a column→value map. +// +// This endpoint is the ENTIRE remote ObjectScript substrate for m-iris: Atelier +// exposes no raw "run ObjectScript" endpoint, and Go has no official IRIS Native +// SDK, so all remote exec / data / cover / admin go through SQL — by calling a +// role-gated, parameterized runner class (see internal/remote) whose methods are +// projected as SQL procedures, then reading results back out of a result global +// (risk B2). Parameters are bound server-side (never string-concatenated) so the +// runner is not a SQL-injection surface. +func (c *Client) Query(ctx context.Context, sql string, params ...string) ([]map[string]string, error) { + u := c.endpoint(c.namespace, "action", "query") + + // Atelier wants parameters as a JSON array; nil-safe so an empty list still + // marshals as [] rather than null. + ps := params + if ps == nil { + ps = []string{} + } + body, err := json.Marshal(struct { + Query string `json:"query"` + Parameters []string `json:"parameters"` + }{Query: sql, Parameters: ps}) + if err != nil { + return nil, fmt.Errorf("atelier: encode query: %w", err) + } + + var env Envelope + if err := c.do(ctx, "POST", u, body, &env); err != nil { + return nil, err + } + return decodeQueryContent(env.Result) +} + +// decodeQueryContent pulls the row set out of an action/query result. The result +// is {content:[ {col:val,…}, … ]}; values come back as JSON scalars, which we +// normalize to strings (SQL over Atelier is string-in/string-out for the runner). +func decodeQueryContent(result json.RawMessage) ([]map[string]string, error) { + if len(result) == 0 { + return nil, nil + } + var wrapped struct { + Content []map[string]json.RawMessage `json:"content"` + } + if err := json.Unmarshal(result, &wrapped); err != nil { + return nil, fmt.Errorf("atelier: decode query result: %w", err) + } + rows := make([]map[string]string, 0, len(wrapped.Content)) + for _, raw := range wrapped.Content { + row := make(map[string]string, len(raw)) + for col, v := range raw { + row[col] = scalarString(v) + } + rows = append(rows, row) + } + return rows, nil +} + +// scalarString renders a JSON scalar as the string a SQL column carried. A JSON +// string is unquoted; anything else (number, bool, null) keeps its literal text. +func scalarString(v json.RawMessage) string { + if len(v) == 0 { + return "" + } + if v[0] == '"' { + var s string + if err := json.Unmarshal(v, &s); err == nil { + return s + } + } + if string(v) == "null" { + return "" + } + return string(v) +} diff --git a/internal/atelier/query_test.go b/internal/atelier/query_test.go new file mode 100644 index 0000000..e7efd20 --- /dev/null +++ b/internal/atelier/query_test.go @@ -0,0 +1,72 @@ +package atelier + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestQuery_RoundTrip drives action/query (the SQL endpoint that is the ENTIRE +// remote ObjectScript substrate — Atelier has no raw "run M" endpoint, so all +// remote exec/data/cover ride a SQL-invokable runner called through here). It +// asserts the request shape (POST {ns}/action/query, {query,parameters}) and +// that the result.content row set is decoded. +func TestQuery_RoundTrip(t *testing.T) { + var gotPath, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"status":{"errors":[]},"console":[],"result":{"content":[`+ + `{"status":"0","error":""}`+ + `]}}`) + })) + defer srv.Close() + + c, err := New(Config{BaseURL: srv.URL + "/api/atelier/v1/", Namespace: "USER"}) + if err != nil { + t.Fatalf("New: %v", err) + } + rows, err := c.Query(context.Background(), + "SELECT m_iris.RunRef(?,?,?) AS status", "rid1", "RUN^STDHARN", "") + if err != nil { + t.Fatalf("Query: %v", err) + } + if !strings.HasSuffix(gotPath, "/USER/action/query") { + t.Errorf("path = %q, want …/USER/action/query", gotPath) + } + var req struct { + Query string `json:"query"` + Parameters []string `json:"parameters"` + } + if err := json.Unmarshal([]byte(gotBody), &req); err != nil { + t.Fatalf("decode request body %q: %v", gotBody, err) + } + if !strings.Contains(req.Query, "RunRef") || len(req.Parameters) != 3 { + t.Errorf("request = %+v, want RunRef + 3 params", req) + } + if len(rows) != 1 || rows[0]["status"] != "0" { + t.Errorf("rows = %v, want one row with status=0", rows) + } +} + +// TestQuery_ServerError surfaces an Atelier-side SQL error as a Go error (e.g. a +// privilege failure on the runner procedure — risk C7). +func TestQuery_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{"status":{"errors":[{"error":"[SQLCODE: <-99>] privilege failure","code":"99"}]},"result":{}}`) + })) + defer srv.Close() + + c, _ := New(Config{BaseURL: srv.URL + "/api/atelier/v1/", Namespace: "USER"}) + if _, err := c.Query(context.Background(), "SELECT 1"); err == nil { + t.Fatal("expected a Go error for a server-side SQL failure") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 3b78126..64cf9bb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,27 +13,27 @@ import ( "os" "strings" - "github.com/vista-cloud-dev/irissync/internal/atelier" - "github.com/vista-cloud-dev/irissync/internal/mirror" + "github.com/vista-cloud-dev/m-iris/internal/atelier" + "github.com/vista-cloud-dev/m-iris/internal/mirror" ) // Conn is embedded in the root CLI struct, so its fields are global flags on // every subcommand, and bound (via kong.Bind) so command Run methods receive a // *Conn. Flags win over env; defaults fill the rest. type Conn struct { - BaseURL string `name:"base-url" env:"IRISSYNC_BASE_URL" help:"Atelier base URL, e.g. https://host:52773/api/atelier/v1/" placeholder:"URL"` - Instance string `env:"IRISSYNC_INSTANCE" help:"Instance label used in the mirror path." placeholder:"NAME"` - Namespace string `env:"IRISSYNC_NAMESPACE" help:"IRIS namespace to liberate." placeholder:"NS"` - Mirror string `env:"IRISSYNC_MIRROR" default:".m-cache" help:"Mirror root directory."` - Type string `env:"IRISSYNC_TYPE" enum:"mac,int,inc" default:"mac" help:"Routine type to liberate: mac (UDL/ObjectScript), int (classic MUMPS — e.g. ^%RI-loaded VistA), or inc (includes)."` - Token string `env:"IRISSYNC_TOKEN" help:"OAuth2/bearer token for Atelier (sent as 'Authorization: Bearer …'; wins over --user/--password)." placeholder:"TOKEN"` - TokenFile string `name:"token-file" env:"IRISSYNC_TOKEN_FILE" help:"Read the bearer token from this file (preferred over --token; keeps the secret out of argv/env)." placeholder:"PATH"` - User string `env:"IRISSYNC_USER" help:"Atelier username (basic auth)."` - Password string `env:"IRISSYNC_PASSWORD" help:"Atelier password (basic auth)."` - PasswordFile string `name:"password-file" env:"IRISSYNC_PASSWORD_FILE" help:"Read the basic-auth password from this file (preferred over --password)." placeholder:"PATH"` - CAFile string `name:"ca-file" env:"IRISSYNC_CA_FILE" help:"Internal CA bundle (PEM) for in-boundary TLS." placeholder:"PATH"` - ClientCert string `name:"client-cert" env:"IRISSYNC_CLIENT_CERT" help:"Client certificate (PEM) for mutual TLS." placeholder:"PATH"` - ClientKey string `name:"client-key" env:"IRISSYNC_CLIENT_KEY" help:"Client private key (PEM) for mutual TLS." placeholder:"PATH"` + BaseURL string `name:"base-url" env:"M_IRIS_BASE_URL" help:"Atelier base URL, e.g. https://host:52773/api/atelier/v1/" placeholder:"URL"` + Instance string `env:"M_IRIS_INSTANCE" help:"Instance label used in the mirror path." placeholder:"NAME"` + Namespace string `env:"M_IRIS_NAMESPACE" help:"IRIS namespace to liberate." placeholder:"NS"` + Mirror string `env:"M_IRIS_MIRROR" default:".m-cache" help:"Mirror root directory."` + Type string `env:"M_IRIS_TYPE" enum:"mac,int,inc" default:"mac" help:"Routine type to liberate: mac (UDL/ObjectScript), int (classic MUMPS — e.g. ^%RI-loaded VistA), or inc (includes)."` + Token string `env:"M_IRIS_TOKEN" help:"OAuth2/bearer token for Atelier (sent as 'Authorization: Bearer …'; wins over --user/--password)." placeholder:"TOKEN"` + TokenFile string `name:"token-file" env:"M_IRIS_TOKEN_FILE" help:"Read the bearer token from this file (preferred over --token; keeps the secret out of argv/env)." placeholder:"PATH"` + User string `env:"M_IRIS_USER" help:"Atelier username (basic auth)."` + Password string `env:"M_IRIS_PASSWORD" help:"Atelier password (basic auth)."` + PasswordFile string `name:"password-file" env:"M_IRIS_PASSWORD_FILE" help:"Read the basic-auth password from this file (preferred over --password)." placeholder:"PATH"` + CAFile string `name:"ca-file" env:"M_IRIS_CA_FILE" help:"Internal CA bundle (PEM) for in-boundary TLS." placeholder:"PATH"` + ClientCert string `name:"client-cert" env:"M_IRIS_CLIENT_CERT" help:"Client certificate (PEM) for mutual TLS." placeholder:"PATH"` + ClientKey string `name:"client-key" env:"M_IRIS_CLIENT_KEY" help:"Client private key (PEM) for mutual TLS." placeholder:"PATH"` Concurrency int `default:"8" help:"Parallel document GETs."` Filter string `help:"Glob filter over docnames (e.g. 'DG*')." placeholder:"GLOB"` Package string `help:"Restrict to a package / routine-name prefix." placeholder:"PREFIX"` diff --git a/internal/driver/caps.go b/internal/driver/caps.go new file mode 100644 index 0000000..4439ef4 --- /dev/null +++ b/internal/driver/caps.go @@ -0,0 +1,53 @@ +// Package driver holds the vendor-neutral m engine-driver contract types +// (driver-contract.md v1.0) as m-iris implements them: the capability document, +// the verb-level Transport seam (local/docker/remote), and the structured +// engine-error shape. These are vendored thin locally until the shared +// m-driver-sdk is extracted at the Phase-0 checkpoint (kickoff-prompts.md +// "Coordination model"); m-iris then depends on the SDK and deletes the copies. +// +// Nothing here knows about m-cli — the driver implements vendor logic only, +// against the frozen contract. +package driver + +// ContractVersion is the driver-contract major.minor this binary implements and +// advertises in caps. m-cli refuses a driver whose major it does not understand. +const ContractVersion = "1.0" + +// Caps is the capability document (driver-contract.md §4). m-cli calls +// `m-iris meta caps` before optional verbs and adapts to exactly what is +// advertised; calling an unadvertised verb yields exit 7 (unsupported). +type Caps struct { + Engine string `json:"engine"` + Contract string `json:"contract"` + Transports []string `json:"transports"` + Axes map[string][]string `json:"axes"` + Features map[string]bool `json:"features"` +} + +// caps is the live document. It is HONEST by construction: it lists only the +// axes/verbs that are actually wired in this build, and grows milestone by +// milestone (M1 lifecycle, M3 exec, M4 data, M5 cover, M6 admin, M7 native). +// Conformance asserts advertised == implemented, so do not list a verb here +// before its command exists. +func capsDoc() Caps { + return Caps{ + Engine: "iris", + Contract: ContractVersion, + Transports: []string{"local", "docker", "remote"}, + Axes: map[string][]string{ + // M0 — meta + the existing irissync source verbs, regrouped under sync. + "meta": {"caps", "version", "info", "schema"}, + "sync": {"list", "pull", "status", "verify", "push", "deploy"}, + }, + Features: map[string]bool{ + "remote": true, // IRIS reaches over Atelier REST + "prune": true, // sync deploy --prune true-sync + "ephemeralPrefix": true, // exec --prefix zzt staging + "snapshot": false, // lifecycle snapshot/rollback — not yet (roadmap §10) + }, + } +} + +// CapsDoc returns the live capability document. Exported as a function (not a +// var) so the slices/maps cannot be mutated by a caller. +func CapsDoc() Caps { return capsDoc() } diff --git a/internal/driver/caps_test.go b/internal/driver/caps_test.go new file mode 100644 index 0000000..4c82323 --- /dev/null +++ b/internal/driver/caps_test.go @@ -0,0 +1,52 @@ +package driver + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// TestCaps_Golden pins the m-iris capability document (driver-contract.md §4). +// caps must be HONEST — it advertises only axes/verbs/transports that are +// actually wired, and grows as milestones land. This golden is the contract +// m-cli reads to decide what it may call. +func TestCaps_Golden(t *testing.T) { + got, err := json.MarshalIndent(CapsDoc(), "", " ") + if err != nil { + t.Fatalf("marshal caps: %v", err) + } + got = append(got, '\n') + + golden := filepath.Join("testdata", "caps.golden.json") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v", err) + } + if string(got) != string(want) { + t.Errorf("caps document drifted from golden\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +// TestCaps_Invariants asserts the fixed facts a consumer relies on regardless +// of which verbs are wired yet. +func TestCaps_Invariants(t *testing.T) { + c := CapsDoc() + if c.Engine != "iris" { + t.Errorf("engine = %q, want iris", c.Engine) + } + if c.Contract != ContractVersion { + t.Errorf("contract = %q, want %q", c.Contract, ContractVersion) + } + // IRIS is the only engine with a remote transport (Atelier REST). + if !c.Features["remote"] { + t.Error("features.remote must be true for IRIS") + } + wantTransports := map[string]bool{"local": true, "docker": true, "remote": true} + for _, tr := range c.Transports { + delete(wantTransports, tr) + } + if len(wantTransports) != 0 { + t.Errorf("missing transports: %v", wantTransports) + } +} diff --git a/internal/driver/fake.go b/internal/driver/fake.go new file mode 100644 index 0000000..9ae3d9d --- /dev/null +++ b/internal/driver/fake.go @@ -0,0 +1,69 @@ +package driver + +import "context" + +// FakeTransport is the injected Transport for unit tests (no engine). It records +// every call and returns canned results, so a command's behavior — argv shape, +// envelope, engineError mapping — is asserted without a real IRIS. The real +// transports appear only in the gated integration tier (driver-plan §1, "No +// hidden engine in unit tests"). +// +// Set the *Fn fields to script behavior; unset verbs return a zero result. +// Calls records an ordered trace for argv/stdin assertions. +type FakeTransport struct { + HealthFn func(ctx context.Context) (Health, error) + LoadFn func(ctx context.Context, req LoadRequest) (LoadResult, error) + ExecFn func(ctx context.Context, req ExecRequest) (ExecResult, error) + ReadGlobalFn func(ctx context.Context, req GlobalRef) (GlobalNode, error) + SetGlobalFn func(ctx context.Context, ref, value string) error + + Calls []FakeCall +} + +// FakeCall is one recorded interaction. +type FakeCall struct { + Verb string + Req any +} + +var _ Transport = (*FakeTransport)(nil) + +func (f *FakeTransport) Health(ctx context.Context) (Health, error) { + f.Calls = append(f.Calls, FakeCall{Verb: "Health"}) + if f.HealthFn != nil { + return f.HealthFn(ctx) + } + return Health{}, nil +} + +func (f *FakeTransport) Load(ctx context.Context, req LoadRequest) (LoadResult, error) { + f.Calls = append(f.Calls, FakeCall{Verb: "Load", Req: req}) + if f.LoadFn != nil { + return f.LoadFn(ctx, req) + } + return LoadResult{}, nil +} + +func (f *FakeTransport) Exec(ctx context.Context, req ExecRequest) (ExecResult, error) { + f.Calls = append(f.Calls, FakeCall{Verb: "Exec", Req: req}) + if f.ExecFn != nil { + return f.ExecFn(ctx, req) + } + return ExecResult{}, nil +} + +func (f *FakeTransport) ReadGlobal(ctx context.Context, req GlobalRef) (GlobalNode, error) { + f.Calls = append(f.Calls, FakeCall{Verb: "ReadGlobal", Req: req}) + if f.ReadGlobalFn != nil { + return f.ReadGlobalFn(ctx, req) + } + return GlobalNode{}, nil +} + +func (f *FakeTransport) SetGlobal(ctx context.Context, ref, value string) error { + f.Calls = append(f.Calls, FakeCall{Verb: "SetGlobal", Req: [2]string{ref, value}}) + if f.SetGlobalFn != nil { + return f.SetGlobalFn(ctx, ref, value) + } + return nil +} diff --git a/internal/driver/testdata/caps.golden.json b/internal/driver/testdata/caps.golden.json new file mode 100644 index 0000000..c64ea67 --- /dev/null +++ b/internal/driver/testdata/caps.golden.json @@ -0,0 +1,31 @@ +{ + "engine": "iris", + "contract": "1.0", + "transports": [ + "local", + "docker", + "remote" + ], + "axes": { + "meta": [ + "caps", + "version", + "info", + "schema" + ], + "sync": [ + "list", + "pull", + "status", + "verify", + "push", + "deploy" + ] + }, + "features": { + "ephemeralPrefix": true, + "prune": true, + "remote": true, + "snapshot": false + } +} diff --git a/internal/driver/transport.go b/internal/driver/transport.go new file mode 100644 index 0000000..f574bba --- /dev/null +++ b/internal/driver/transport.go @@ -0,0 +1,97 @@ +package driver + +import ( + "context" + + "github.com/vista-cloud-dev/m-iris/clikit" +) + +// Transport is the verb-level seam every IRIS transport — local, docker, +// remote — implements. It is deliberately NOT a low-level run(argv) (risk B1): +// +// - local/docker exec pipes ObjectScript to `iris session -U NS` (stdin → +// stdout) and compiles via $SYSTEM.OBJ.Load; +// - remote exec is Atelier PUT + action/compile + a SQL action/query into a +// role-gated runner class — there is NO raw "run ObjectScript" endpoint, no +// stdout; results come back through a result global the transport reads. +// +// A single argv seam cannot model both shapes, so the contract is verb-level: +// each transport implements its own strategy and the rest of the driver is +// transport-agnostic. This is the interface m-iris contributes to the shared +// m-driver-sdk at the Phase-0 checkpoint (it must also fit m-ydb's session-pipe). +type Transport interface { + // Health is the readiness/liveness probe behind `lifecycle status --probe` + // and `wait`. remote: GET /api/atelier/v1/ → 200 + version. + Health(ctx context.Context) (Health, error) + + // Load stages routine source and compiles it (exec load). local/docker: + // $SYSTEM.OBJ.Load(path,"ck"); remote: Atelier PUT + action/compile. + Load(ctx context.Context, req LoadRequest) (LoadResult, error) + + // Exec runs an entryref (with args) or evaluates a single M command (exec + // run / eval). On a compile/runtime fault it returns ok via the result's + // EngineError, not a Go error — the fault is data (driver-contract §7). + Exec(ctx context.Context, req ExecRequest) (ExecResult, error) + + // ReadGlobal reads a global node (or subtree, per Depth) — data get/query + // and the result-global reads that back exec/cover orchestration. + ReadGlobal(ctx context.Context, req GlobalRef) (GlobalNode, error) + + // SetGlobal sets a single global node (data set), used to seed fixtures. + SetGlobal(ctx context.Context, ref, value string) error +} + +// Health is the probe result (driver-contract §3 health probes). +type Health struct { + Running bool `json:"running"` + Healthy bool `json:"healthy"` + Version string `json:"version,omitempty"` + LatencyMs int64 `json:"latencyMs"` +} + +// LoadRequest stages source for exec. Paths are files or a directory of +// routine source; Prefix (e.g. zzt) namespaces an ephemeral run so +// teardown is scoped to that prefix. +type LoadRequest struct { + Paths []string + Prefix string +} + +// LoadResult reports what was staged + compiled. +type LoadResult struct { + Loaded []string `json:"loaded"` +} + +// ExecRequest runs an entryref or evaluates a command. EntryRef and Command are +// mutually exclusive (run vs eval). +type ExecRequest struct { + EntryRef string // e.g. RUN^STDHARN (run) + Args []string // positional args → $ZCMDLINE / the entry's formallist + Command string // a single M command (eval) + Prefix string // ephemeral-run prefix +} + +// ExecResult is the unified outcome. Stdout is the captured device output +// (local/docker) or the runner's result-global text (remote). EngineError, when +// non-nil, is the §7 structured fault — the transport sets it instead of +// returning a Go error so the caller can render a RED-with-cause envelope. +type ExecResult struct { + Stdout string `json:"stdout"` + Status int `json:"status"` + EngineError *clikit.EngineError `json:"engineError,omitempty"` +} + +// GlobalRef addresses a global for a read. Order/Depth shape a subtree query +// (data query); empty means a single-node get. +type GlobalRef struct { + Ref string + Order string // "forward" | "reverse" + Depth int // 0 = this node only +} + +// GlobalNode is a global value, with children for a subtree read. +type GlobalNode struct { + Ref string `json:"ref"` + Value string `json:"value,omitempty"` + Nodes []GlobalNode `json:"nodes,omitempty"` +} diff --git a/internal/driver/transport_test.go b/internal/driver/transport_test.go new file mode 100644 index 0000000..4cf0a95 --- /dev/null +++ b/internal/driver/transport_test.go @@ -0,0 +1,60 @@ +package driver + +import ( + "context" + "testing" + + "github.com/vista-cloud-dev/m-iris/clikit" +) + +// TestFakeTransport_RecordsAndScripts verifies the fake satisfies Transport, +// records calls in order, and returns scripted results — the substrate every +// unit test injects in place of a real IRIS. +func TestFakeTransport_RecordsAndScripts(t *testing.T) { + ctx := context.Background() + ft := &FakeTransport{ + ExecFn: func(_ context.Context, req ExecRequest) (ExecResult, error) { + if req.EntryRef == "BROKEN^X" { + return ExecResult{ + EngineError: &clikit.EngineError{ + Routine: "X", Line: 3, Mnemonic: "", Text: "no such routine", + }, + }, nil + } + return ExecResult{Stdout: "ok", Status: 0}, nil + }, + } + + // A clean run returns canned stdout. + res, err := ft.Exec(ctx, ExecRequest{EntryRef: "RUN^STDHARN"}) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if res.Stdout != "ok" || res.Status != 0 { + t.Errorf("clean run = %+v, want stdout=ok status=0", res) + } + + // A fault is data, not a Go error: EngineError is populated, err is nil. + res, err = ft.Exec(ctx, ExecRequest{EntryRef: "BROKEN^X"}) + if err != nil { + t.Fatalf("fault should be data, not error: %v", err) + } + if res.EngineError == nil || res.EngineError.Mnemonic != "" { + t.Errorf("EngineError = %+v, want ", res.EngineError) + } + + // Unset verbs are safe zero-value no-ops and still record. + if _, err := ft.Health(ctx); err != nil { + t.Fatalf("Health: %v", err) + } + + wantVerbs := []string{"Exec", "Exec", "Health"} + if len(ft.Calls) != len(wantVerbs) { + t.Fatalf("recorded %d calls, want %d", len(ft.Calls), len(wantVerbs)) + } + for i, v := range wantVerbs { + if ft.Calls[i].Verb != v { + t.Errorf("call %d verb = %q, want %q", i, ft.Calls[i].Verb, v) + } + } +} diff --git a/internal/remote/integration_test.go b/internal/remote/integration_test.go new file mode 100644 index 0000000..2c3270e --- /dev/null +++ b/internal/remote/integration_test.go @@ -0,0 +1,98 @@ +package remote + +import ( + "context" + "os" + "testing" + "time" + + "github.com/vista-cloud-dev/m-iris/internal/atelier" + "github.com/vista-cloud-dev/m-iris/internal/driver" +) + +// TestRemoteSpike_RealEngine is the REMOTE SPIKE (driver-plan §5 task 8): it +// proves, against a real IRIS, that the runner class deploys over Atelier and +// the whole remote substrate round-trips — set/get a global, Eval a command, +// and surface a real fault as a structured EngineError. Make this green once and +// every other remote feature (exec/data/cover/admin) is de-risked, because they +// all ride exactly this path. +// +// Gated: it only runs with M_IRIS_IT=1 and an Atelier target in M_IRIS_* env +// (the same connection vars the driver uses). The fake-API unit tests above run +// every commit; this real-engine tier is nightly/CI (containers are minutes). +// +// M_IRIS_IT=1 \ +// M_IRIS_BASE_URL=http://localhost:52773/api/atelier/v1/ \ +// M_IRIS_NAMESPACE=USER M_IRIS_USER=_SYSTEM M_IRIS_PASSWORD=SYS \ +// go test ./internal/remote/ -run TestRemoteSpike_RealEngine -v +func TestRemoteSpike_RealEngine(t *testing.T) { + if os.Getenv("M_IRIS_IT") != "1" { + t.Skip("set M_IRIS_IT=1 (+ M_IRIS_* connection env) to run the real-engine remote spike") + } + base := envOr("M_IRIS_BASE_URL", "http://localhost:52773/api/atelier/v1/") + ns := envOr("M_IRIS_NAMESPACE", "USER") + client, err := atelier.New(atelier.Config{ + BaseURL: base, + Namespace: ns, + User: envOr("M_IRIS_USER", "_SYSTEM"), + Password: envOr("M_IRIS_PASSWORD", "SYS"), + Timeout: 30 * time.Second, + }) + if err != nil { + t.Fatalf("atelier client: %v", err) + } + tr := New(client) + ctx := context.Background() + + // Teardown: drop the test globals and the runner doc. + t.Cleanup(func() { + _, _ = client.Query(ctx, "SELECT m_iris.KillGlobal(?)", `^mIrisRun("zzit")`) + _, _ = client.Query(ctx, "SELECT m_iris.KillGlobal(?)", `^mIrisIT`) + _ = client.DeleteDoc(ctx, runnerDoc) + }) + + // 1. data set/get round-trips through the runner (deploys it on first use). + if err := tr.SetGlobal(ctx, `^mIrisIT("ping")`, "pong"); err != nil { + t.Fatalf("SetGlobal: %v", err) + } + node, err := tr.ReadGlobal(ctx, driver.GlobalRef{Ref: `^mIrisIT("ping")`}) + if err != nil { + t.Fatalf("ReadGlobal: %v", err) + } + if node.Value != "pong" { + t.Fatalf("global read-back = %q, want pong", node.Value) + } + + // 2. Eval a command; its side effect is visible through a result-global read. + if _, err := tr.Exec(ctx, driver.ExecRequest{ + Command: `set ^mIrisRun("zzit","out")="evaled"`, Prefix: "zzit", + }); err != nil { + t.Fatalf("Exec eval: %v", err) + } + out, err := tr.ReadGlobal(ctx, driver.GlobalRef{Ref: `^mIrisRun("zzit","out")`}) + if err != nil { + t.Fatalf("ReadGlobal out: %v", err) + } + if out.Value != "evaled" { + t.Fatalf("eval side effect = %q, want evaled", out.Value) + } + + // 3. A deliberate fault surfaces as a structured EngineError, not a Go error. + res, err := tr.Exec(ctx, driver.ExecRequest{ + Command: `set x=^mIrisNoSuchGlobal(1)`, Prefix: "zzfault", + }) + if err != nil { + t.Fatalf("fault Exec returned a Go error (should be data): %v", err) + } + if res.EngineError == nil || res.EngineError.Mnemonic == "" { + t.Fatalf("expected a structured EngineError, got %+v", res) + } + t.Logf("engineError surfaced: %+v", res.EngineError) +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/remote/remote.go b/internal/remote/remote.go new file mode 100644 index 0000000..3766036 --- /dev/null +++ b/internal/remote/remote.go @@ -0,0 +1,256 @@ +// Package remote is the IRIS `remote` transport: vendor logic that drives an +// IRIS namespace entirely over the Atelier REST API. Because Atelier has no raw +// "run ObjectScript" endpoint, every ObjectScript operation rides the +// m_iris.Runner class (runner/m_iris.Runner.cls): the transport PUT+compiles it +// once, then invokes its SQL-projected procedures via action/query and reads +// results back out of a result global. This is the entire remote substrate +// (driver-plan §5 task 8, risk B2); remote exec/data/cover/admin all sit on it. +package remote + +import ( + "context" + _ "embed" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/atelier" + "github.com/vista-cloud-dev/m-iris/internal/driver" +) + +//go:embed runner/m_iris.Runner.cls +var runnerSource string + +// runnerDoc is the Atelier docname of the runner class. +const runnerDoc = "m_iris.Runner.cls" + +// AtelierAPI is the slice of the Atelier client the remote transport needs. It +// is narrowed to an interface so unit tests inject a fake (recording PUT/Compile +// and scripting Query rows) without an HTTP server — the real *atelier.Client is +// the gated integration tier. +type AtelierAPI interface { + PutDoc(ctx context.Context, name string, content []string) (*atelier.PutResult, error) + Compile(ctx context.Context, names []string, flags string) (*atelier.CompileResult, error) + Query(ctx context.Context, sql string, params ...string) ([]map[string]string, error) +} + +// Transport is the remote (Atelier REST + SQL runner) strategy. It satisfies +// driver.Transport so the rest of m-iris is transport-agnostic. +type Transport struct { + api AtelierAPI + deployed bool // runner PUT+compiled this process +} + +var _ driver.Transport = (*Transport)(nil) + +// New builds a remote transport over an Atelier client. +func New(api AtelierAPI) *Transport { return &Transport{api: api} } + +// ensureRunner PUT+compiles the runner class once. It is idempotent: a fresh +// instance compiles it; subsequent calls are a no-op. (Lazy so a transport that +// only ever reads source — sync — never deploys the runner.) +func (t *Transport) ensureRunner(ctx context.Context) error { + if t.deployed { + return nil + } + lines := strings.Split(strings.TrimRight(runnerSource, "\n"), "\n") + if _, err := t.api.PutDoc(ctx, runnerDoc, lines); err != nil { + return fmt.Errorf("remote: deploy runner: %w", err) + } + res, err := t.api.Compile(ctx, []string{runnerDoc}, "cuk") + if err != nil { + return fmt.Errorf("remote: compile runner: %w", err) + } + if res != nil && !res.OK() { + return fmt.Errorf("remote: runner did not compile: %s", strings.Join(res.Diagnostics, "; ")) + } + t.deployed = true + return nil +} + +// runID derives the result-global key for a request; the ephemeral --prefix is +// the natural run id, falling back to a fixed key for one-shot calls. +func runID(prefix string) string { + if prefix != "" { + return prefix + } + return "m" +} + +// Exec runs an entryref or evaluates a command through the runner. A compile/ +// runtime fault is data, not a Go error: the runner records it in the result +// global and Exec returns it as ExecResult.EngineError (contract §7). +func (t *Transport) Exec(ctx context.Context, req driver.ExecRequest) (driver.ExecResult, error) { + if err := t.ensureRunner(ctx); err != nil { + return driver.ExecResult{}, err + } + rid := runID(req.Prefix) + + var rows []map[string]string + var err error + switch { + case req.Command != "": + rows, err = t.api.Query(ctx, "SELECT m_iris.Eval(?,?) AS status", rid, req.Command) + case req.EntryRef != "": + rows, err = t.api.Query(ctx, "SELECT m_iris.RunRef(?,?,?) AS status", + rid, req.EntryRef, strings.Join(req.Args, "\x01")) + default: + return driver.ExecResult{}, fmt.Errorf("remote: exec needs an entryref or a command") + } + if err != nil { + return driver.ExecResult{}, err + } + + status := firstCol(rows, "status") + switch status { + case "7": + return driver.ExecResult{}, fmt.Errorf("remote: runner refused — caller lacks the m_iris_runner role / action-query privilege") + case "5": + eng, rerr := t.readEngineError(ctx, rid) + if rerr != nil { + return driver.ExecResult{}, rerr + } + return driver.ExecResult{Status: 5, EngineError: eng}, nil + } + + out, err := t.getGlobal(ctx, fmt.Sprintf(`^mIrisRun(%q,"out")`, rid)) + if err != nil { + return driver.ExecResult{}, err + } + st, _ := strconv.Atoi(status) + return driver.ExecResult{Stdout: out, Status: st}, nil +} + +// readEngineError reads ^mIrisRun(rid,"error") and parses the §7 frame +// "mnemonic|routine|line|text". +func (t *Transport) readEngineError(ctx context.Context, rid string) (*clikit.EngineError, error) { + raw, err := t.getGlobal(ctx, fmt.Sprintf(`^mIrisRun(%q,"error")`, rid)) + if err != nil { + return nil, err + } + parts := strings.SplitN(raw, "|", 4) + eng := &clikit.EngineError{} + if len(parts) > 0 { + eng.Mnemonic = parts[0] + } + if len(parts) > 1 { + eng.Routine = parts[1] + } + if len(parts) > 2 { + eng.Line, _ = strconv.Atoi(parts[2]) + } + if len(parts) > 3 { + eng.Text = parts[3] + } + return eng, nil +} + +// ReadGlobal reads a single global node via the runner (contract data.get). +func (t *Transport) ReadGlobal(ctx context.Context, req driver.GlobalRef) (driver.GlobalNode, error) { + if err := t.ensureRunner(ctx); err != nil { + return driver.GlobalNode{}, err + } + v, err := t.getGlobal(ctx, req.Ref) + if err != nil { + return driver.GlobalNode{}, err + } + return driver.GlobalNode{Ref: req.Ref, Value: v}, nil +} + +// SetGlobal sets a single global node via the runner (contract data.set). +func (t *Transport) SetGlobal(ctx context.Context, ref, value string) error { + if err := t.ensureRunner(ctx); err != nil { + return err + } + if _, err := t.api.Query(ctx, "SELECT m_iris.SetGlobal(?,?) AS ok", ref, value); err != nil { + return err + } + return nil +} + +func (t *Transport) getGlobal(ctx context.Context, ref string) (string, error) { + rows, err := t.api.Query(ctx, "SELECT m_iris.GetGlobal(?) AS value", ref) + if err != nil { + return "", err + } + return firstCol(rows, "value"), nil +} + +// Load PUT+compiles routine source over Atelier (contract exec.load on remote). +// Compile diagnostics are surfaced as an EngineError rather than a Go error — +// a failed compile is a bad result, not a transport failure. +func (t *Transport) Load(ctx context.Context, req driver.LoadRequest) (driver.LoadResult, error) { + files, err := expandPaths(req.Paths) + if err != nil { + return driver.LoadResult{}, err + } + var loaded []string + for _, f := range files { + content, rerr := os.ReadFile(f) + if rerr != nil { + return driver.LoadResult{}, rerr + } + name := req.Prefix + filepath.Base(f) + if _, perr := t.api.PutDoc(ctx, name, splitLines(string(content))); perr != nil { + return driver.LoadResult{}, perr + } + loaded = append(loaded, name) + } + if len(loaded) > 0 { + if _, cerr := t.api.Compile(ctx, loaded, "cuk"); cerr != nil { + return driver.LoadResult{}, cerr + } + } + return driver.LoadResult{Loaded: loaded}, nil +} + +// Health proves the remote substrate is reachable AND that the caller actually +// holds the action/query privilege (a SELECT 1, not just TCP reachability — +// risks C3, C7). Version enrichment lands with the M1 root-endpoint probe. +func (t *Transport) Health(ctx context.Context) (driver.Health, error) { + rows, err := t.api.Query(ctx, "SELECT 1 AS one") + if err != nil { + return driver.Health{Running: false, Healthy: false}, err + } + healthy := firstCol(rows, "one") == "1" + return driver.Health{Running: true, Healthy: healthy}, nil +} + +func firstCol(rows []map[string]string, col string) string { + if len(rows) == 0 { + return "" + } + return rows[0][col] +} + +func splitLines(s string) []string { + return strings.Split(strings.TrimRight(s, "\n"), "\n") +} + +// expandPaths flattens files and directories into a routine-source file list. +func expandPaths(paths []string) ([]string, error) { + var out []string + for _, p := range paths { + info, err := os.Stat(p) + if err != nil { + return nil, err + } + if !info.IsDir() { + out = append(out, p) + continue + } + entries, err := os.ReadDir(p) + if err != nil { + return nil, err + } + for _, e := range entries { + if !e.IsDir() { + out = append(out, filepath.Join(p, e.Name())) + } + } + } + return out, nil +} diff --git a/internal/remote/remote_test.go b/internal/remote/remote_test.go new file mode 100644 index 0000000..657bc31 --- /dev/null +++ b/internal/remote/remote_test.go @@ -0,0 +1,142 @@ +package remote + +import ( + "context" + "strings" + "testing" + + "github.com/vista-cloud-dev/m-iris/internal/atelier" + "github.com/vista-cloud-dev/m-iris/internal/driver" +) + +// fakeAPI scripts the runner's SQL surface in-memory: it records PUT/Compile +// (so we can assert the runner is deployed exactly once) and answers Query by +// dispatching on the SQL + bound parameters, modelling ^mIrisRun. +type fakeAPI struct { + puts []string + compiles [][]string + globals map[string]string // global ref → value (the result global) + runFault *clikit3Engine // if set, the next RunRef faults with this frame +} + +// clikit3Engine mirrors the runner's "mnemonic|routine|line|text" error frame. +type clikit3Engine struct{ mnemonic, routine, line, text string } + +func newFakeAPI() *fakeAPI { return &fakeAPI{globals: map[string]string{}} } + +func (f *fakeAPI) PutDoc(_ context.Context, name string, _ []string) (*atelier.PutResult, error) { + f.puts = append(f.puts, name) + return &atelier.PutResult{Name: name}, nil +} + +func (f *fakeAPI) Compile(_ context.Context, names []string, _ string) (*atelier.CompileResult, error) { + f.compiles = append(f.compiles, names) + return &atelier.CompileResult{}, nil +} + +func (f *fakeAPI) Query(_ context.Context, sql string, params ...string) ([]map[string]string, error) { + switch { + case strings.Contains(sql, "RunRef"): + rid := params[0] + if f.runFault != nil { + f.globals[`^mIrisRun("`+rid+`","status")`] = "5" + f.globals[`^mIrisRun("`+rid+`","error")`] = strings.Join( + []string{f.runFault.mnemonic, f.runFault.routine, f.runFault.line, f.runFault.text}, "|") + return []map[string]string{{"status": "5"}}, nil + } + f.globals[`^mIrisRun("`+rid+`","status")`] = "0" + return []map[string]string{{"status": "0"}}, nil + case strings.Contains(sql, "Eval"): + return []map[string]string{{"status": "0"}}, nil + case strings.Contains(sql, "SetGlobal"): + f.globals[params[0]] = params[1] + return []map[string]string{{"ok": "1"}}, nil + case strings.Contains(sql, "GetGlobal"): + return []map[string]string{{"value": f.globals[params[0]]}}, nil + case strings.Contains(sql, "SELECT 1"): + return []map[string]string{{"one": "1"}}, nil + } + return nil, nil +} + +// TestRemoteExec_DeploysRunnerOnceAndRunsClean proves the spike round-trip: the +// runner is PUT+compiled on first use (once), and a clean RunRef returns status 0. +func TestRemoteExec_DeploysRunnerOnceAndRunsClean(t *testing.T) { + api := newFakeAPI() + tr := New(api) + ctx := context.Background() + + res, err := tr.Exec(ctx, driver.ExecRequest{EntryRef: "RUN^STDHARN", Prefix: "zzt42"}) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if res.Status != 0 || res.EngineError != nil { + t.Errorf("clean run = %+v, want status 0 no engineError", res) + } + // Runner deployed exactly once... + if len(api.puts) != 1 || api.puts[0] != runnerDoc { + t.Errorf("puts = %v, want one %s", api.puts, runnerDoc) + } + if len(api.compiles) != 1 { + t.Errorf("compiles = %v, want one", api.compiles) + } + // ...and not re-deployed on a second call. + if _, err := tr.Exec(ctx, driver.ExecRequest{EntryRef: "OTHER^RTN", Prefix: "zzt42"}); err != nil { + t.Fatalf("second Exec: %v", err) + } + if len(api.puts) != 1 { + t.Errorf("runner re-deployed: puts = %v", api.puts) + } +} + +// TestRemoteExec_FaultBecomesEngineError proves a runtime fault flows back out of +// the result global as a structured §7 EngineError (not a Go error) — the whole +// point of routing remote exec through the runner. +func TestRemoteExec_FaultBecomesEngineError(t *testing.T) { + api := newFakeAPI() + api.runFault = &clikit3Engine{mnemonic: "", routine: "XLFISO", line: "12", text: "global undefined"} + tr := New(api) + + res, err := tr.Exec(context.Background(), driver.ExecRequest{EntryRef: "BROKEN^XLFISO", Prefix: "zzt7"}) + if err != nil { + t.Fatalf("a fault must be data, not a Go error: %v", err) + } + if res.EngineError == nil { + t.Fatal("expected an EngineError") + } + if res.EngineError.Mnemonic != "" || res.EngineError.Routine != "XLFISO" || res.EngineError.Line != 12 { + t.Errorf("engineError = %+v, want XLFISO:12", res.EngineError) + } +} + +// TestRemoteData_SetGetRoundTrip proves data.set/get ride the same substrate. +func TestRemoteData_SetGetRoundTrip(t *testing.T) { + api := newFakeAPI() + tr := New(api) + ctx := context.Background() + + ref := `^mIrisFix("k")` + if err := tr.SetGlobal(ctx, ref, "hello"); err != nil { + t.Fatalf("SetGlobal: %v", err) + } + node, err := tr.ReadGlobal(ctx, driver.GlobalRef{Ref: ref}) + if err != nil { + t.Fatalf("ReadGlobal: %v", err) + } + if node.Value != "hello" { + t.Errorf("read-back = %q, want hello", node.Value) + } +} + +// TestRemoteHealth_ProbesQueryPrivilege proves Health asserts the action/query +// privilege (SELECT 1), not just TCP reachability. +func TestRemoteHealth_ProbesQueryPrivilege(t *testing.T) { + tr := New(newFakeAPI()) + h, err := tr.Health(context.Background()) + if err != nil { + t.Fatalf("Health: %v", err) + } + if !h.Running || !h.Healthy { + t.Errorf("health = %+v, want running+healthy", h) + } +} diff --git a/internal/remote/runner/m_iris.Runner.cls b/internal/remote/runner/m_iris.Runner.cls new file mode 100644 index 0000000..d839a61 --- /dev/null +++ b/internal/remote/runner/m_iris.Runner.cls @@ -0,0 +1,105 @@ +/// m_iris.Runner is the remote execution substrate for the m-iris driver. +/// +/// Atelier exposes no raw "run ObjectScript" endpoint, and Go has no official +/// IRIS Native SDK — so on the `remote` transport ALL ObjectScript (exec, data, +/// cover, admin) rides this class: its methods are projected as SQL procedures, +/// invoked via POST .../action/query, and their results are read back out of a +/// result global. This one class is therefore the entire remote substrate +/// (driver-plan §5 task 8, risk B2). It is a security boundary, so every entry +/// point is role-gated AND parameterized (callers bind values, never concatenate). +/// +/// Result global, keyed by a caller-supplied run id (rid): +/// ^mIrisRun(rid,"status") = 0 ok | 5 fault | 7 unauthorized +/// ^mIrisRun(rid,"error") = "mnemonic|routine|line|text" (on fault, contract §7) +Class m_iris.Runner Extends %RegisteredObject +{ + +/// authorized reports whether the caller may drive the substrate. Holding the +/// m_iris_runner role is the gate; _SYSTEM is allowed so a fresh instance works +/// before the role is provisioned. At deploy, SQL EXECUTE on these procedures is +/// granted only to m_iris_runner (defense in depth: app-role AND SQL privilege). +ClassMethod authorized() As %Boolean [ Private ] +{ + quit ($SYSTEM.Security.Check("m_iris_runner", "USE") '= "") || ($USERNAME = "_SYSTEM") +} + +/// fault records a caught exception into the result global in contract-§7 shape +/// (mnemonic|routine|line|text) and returns status 5. +ClassMethod fault(rid As %String, ex As %Exception.AbstractException) As %Integer [ Private ] +{ + set loc = ex.Location + set routine = $piece(loc, "^", 2) + set line = $piece($piece(loc, "^", 1), "+", 2) + set ^mIrisRun(rid, "status") = 5 + set ^mIrisRun(rid, "error") = ex.Name _ "|" _ routine _ "|" _ line _ "|" _ ex.DisplayString() + quit 5 +} + +/// RunRef executes entryref `ref` (e.g. "RUN^STDHARN"). The entryref owns any +/// payload it writes; this method only drives execution and traps faults. +/// SQL: SELECT m_iris.RunRef(?,?,?) +ClassMethod RunRef(rid As %String, ref As %String, args As %String = "") As %Integer [ SqlName = "RunRef", SqlProc ] +{ + if '..authorized() quit 7 + kill ^mIrisRun(rid) + set ^mIrisRun(rid, "status") = 0 + try { + do @ref + } catch ex { + quit ..fault(rid, ex) + } + quit ^mIrisRun(rid, "status") +} + +/// Eval executes one ObjectScript command line via XECUTE (contract exec.eval). +/// SQL: SELECT m_iris.Eval(?,?) +ClassMethod Eval(rid As %String, cmd As %String) As %Integer [ SqlName = "Eval", SqlProc ] +{ + if '..authorized() quit 7 + kill ^mIrisRun(rid) + set ^mIrisRun(rid, "status") = 0 + try { + xecute cmd + } catch ex { + quit ..fault(rid, ex) + } + quit ^mIrisRun(rid, "status") +} + +/// GetGlobal returns $get(@ref); ref is a full global reference, e.g. +/// "^mIrisRun(""r1"",""status"")". (contract data.get + result-global reads) +/// SQL: SELECT m_iris.GetGlobal(?) +ClassMethod GetGlobal(ref As %String) As %String [ SqlName = "GetGlobal", SqlProc ] +{ + if '..authorized() quit "" + quit $get(@ref) +} + +/// SetGlobal sets @ref=value (contract data.set, fixture seeding). +/// SQL: SELECT m_iris.SetGlobal(?,?) +ClassMethod SetGlobal(ref As %String, value As %String = "") As %Integer [ SqlName = "SetGlobal", SqlProc ] +{ + if '..authorized() quit 7 + set @ref = value + quit 1 +} + +/// KillGlobal kills @ref (contract data.kill; ephemeral-prefix teardown). +/// SQL: SELECT m_iris.KillGlobal(?) +ClassMethod KillGlobal(ref As %String) As %Integer [ SqlName = "KillGlobal", SqlProc ] +{ + if '..authorized() quit 7 + kill @ref + quit 1 +} + +/// Ping returns $zversion — a cheap readiness/version probe through the same +/// substrate, so `doctor` can prove the action/query privilege, not just +/// reachability (risks C3, C7). +/// SQL: SELECT m_iris.Ping() +ClassMethod Ping() As %String [ SqlName = "Ping", SqlProc ] +{ + quit $zversion +} + +} diff --git a/main.go b/main.go index 02e8ae1..288c1b9 100644 --- a/main.go +++ b/main.go @@ -1,23 +1,28 @@ -// Command irissync is the sole bidirectional owner of the IRIS source boundary: -// it materializes the M routines of a namespace into a git-friendly mirror + -// manifest (the read side), and writes edited routines back to IRIS (push). +// Command m-iris is the InterSystems IRIS engine driver for the `m` toolchain: +// a vendor adapter exposing the neutral m engine-driver contract (driver- +// contract.md v1.0) over IRIS, plus the complete native IRIS surface for power +// users. It is the rename + extension of the original `irissync` (whose Atelier +// source axis became the `sync` axis here). // -// The read verbs (list/pull/status/verify) are safe by construction — every -// IRIS operation is a GET; the only writes are to the local mirror. `push` is -// the opt-in write path and the SOLE DB WRITER: it is gated by a single-writer -// lock + a manifest conflict-check + detect-and-defer (liberation-binary-design -// §5) so it never clobbers a change made underneath it. +// The contract surface is grouped into axes — m-cli speaks only these: // -// irissync list inventory server docnames (connectivity/auth smoke test) -// irissync pull DB → .mac mirror + manifest, incremental -// irissync status server vs. local manifest drift (exit 3 on drift) -// irissync verify re-hash mirror files against the manifest (exit 3 on mismatch) -// irissync push write edited routines back to IRIS (PUT + compile), conflict-checked, locked (exit 4 on refusal) -// irissync version build + Go toolchain info -// irissync schema machine-readable command tree (agent discovery) +// m-iris meta caps capability document (axes/transports/features) +// m-iris meta info driver identity + resolved engine target +// m-iris meta version build + Go toolchain info +// m-iris meta schema machine-readable command tree (agent discovery) +// m-iris sync list inventory server docnames (connectivity + inventory) +// m-iris sync pull DB → mirror + manifest, incremental +// m-iris sync status server vs. local manifest drift (exit 3 on drift) +// m-iris sync verify re-hash mirror files against the manifest (exit 3) +// m-iris sync push write edited routines back to IRIS (the sole DB writer) +// m-iris sync deploy install a routine-source library (--prune true-sync) // -// Connection config comes from flags or IRISSYNC_* env (flags win); see -// internal/config and liberation-binary-design.md §2/§3. +// Later milestones add the lifecycle, exec, data, cover, admin, and native +// axes; caps grows to advertise each as it lands (caps is honest by +// construction — advertised == implemented). +// +// Connection config comes from flags or M_IRIS_* env (flags win); see +// internal/config. Transports: local | docker | remote (Atelier REST). package main import ( @@ -26,34 +31,44 @@ import ( "github.com/alecthomas/kong" "github.com/willabides/kongplete" - "github.com/vista-cloud-dev/irissync/clikit" - "github.com/vista-cloud-dev/irissync/internal/config" + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/config" ) // CLI is the root command grammar. clikit.Globals (--output/--no-color/-v) and // config.Conn (connection + behavior flags) are embedded, so both contribute -// global flags; config.Conn is bound so commands receive a *config.Conn. +// global flags; config.Conn is bound so commands receive a *config.Conn. The +// contract verbs are grouped into axis subcommands (meta, sync, …). type CLI struct { clikit.Globals config.Conn + Meta metaCmd `cmd:"" help:"Introspection + power tools: caps / info / version / schema."` + Sync syncCmd `cmd:"" help:"Source axis: routine source ↔ instance (list / pull / status / verify / push / deploy)."` + + InstallCompletions kongplete.InstallCompletions `cmd:"" help:"Install shell tab-completions."` +} + +// syncCmd is the sync axis (driver-contract §5.2) — the original irissync source +// verbs, regrouped. The read verbs (list/pull/status/verify) are safe by +// construction (every IRIS operation is a GET; writes go only to the local +// mirror); push is the opt-in write path and the sole DB writer (locked + +// conflict-checked); deploy installs a routine-source library. M2 adds diff/rm +// and the bare-name --filter. +type syncCmd struct { List listCmd `cmd:"" help:"List server routine docnames (no writes) — connectivity + inventory."` - Pull pullCmd `cmd:"" help:"Materialize IRIS routine source → .mac mirror, incremental via the manifest."` + Pull pullCmd `cmd:"" help:"Materialize IRIS routine source → mirror, incremental via the manifest."` Status statusCmd `cmd:"" help:"Diff server vs. local manifest: new / changed / deleted (exit 3 on drift)."` Verify verifyCmd `cmd:"" help:"Re-hash mirror files against the manifest (exit 3 on mismatch)."` Push pushCmd `cmd:"" help:"Write edited routines back to IRIS (PUT + compile) — the sole DB writer; conflict-checked + single-writer-locked (exit 4 on refusal)."` - - Schema clikit.SchemaCmd `cmd:"" help:"Emit the command/flag tree as JSON (agent discovery)."` - Version clikit.VersionCmd `cmd:"" help:"Show version and build info."` - - InstallCompletions kongplete.InstallCompletions `cmd:"" help:"Install shell tab-completions."` + Deploy deployCmd `cmd:"" help:"Install a routine-source library (e.g. m-stdlib/src) into a namespace over Atelier (PUT + compile); --prune for a true sync."` } func main() { cli := &CLI{} os.Exit(clikit.Run( - "irissync", - "IRIS source-sync — materialize IRIS routine source to a .mac mirror (read), and write edited routines back (push, the sole DB writer).", + "m-iris", + "InterSystems IRIS engine driver for the m toolchain — neutral contract verbs (meta, sync, …) over IRIS, plus the native IRIS surface.", cli, &cli.Globals, kong.Bind(&cli.Conn), )) diff --git a/main_test.go b/main_test.go index f9bee8d..205265a 100644 --- a/main_test.go +++ b/main_test.go @@ -13,8 +13,8 @@ import ( "strings" "testing" - "github.com/vista-cloud-dev/irissync/clikit" - "github.com/vista-cloud-dev/irissync/internal/config" + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/config" ) // fakeAtelier serves the read-side endpoints with the given routine contents diff --git a/meta.go b/meta.go new file mode 100644 index 0000000..414dc26 --- /dev/null +++ b/meta.go @@ -0,0 +1,80 @@ +package main + +import ( + "fmt" + + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/config" + "github.com/vista-cloud-dev/m-iris/internal/driver" +) + +// metaCmd is the meta axis (driver-contract §5.7): introspection + power tools. +// caps/version/info/schema are wired now; doctor (M1), selftest (M8), native + +// shell (M7) join as their milestones land — and caps grows to advertise them. +type metaCmd struct { + Caps capsCmd `cmd:"" help:"Emit the capability document (axes, transports, features) m-cli reads before calling optional verbs."` + Info infoCmd `cmd:"" help:"Driver identity + resolved engine target (edition/version filled by the M1 probe)."` + Version clikit.VersionCmd `cmd:"" help:"Show version and build info."` + Schema clikit.SchemaCmd `cmd:"" help:"Emit the command/flag tree as JSON (agent discovery)."` +} + +// --- meta caps --------------------------------------------------------------- + +type capsCmd struct{} + +// Run emits the live capability document. It needs no connection — caps is a +// pure description of what this binary can do. +func (capsCmd) Run(cc *clikit.Context) error { + caps := driver.CapsDoc() + return cc.Result(caps, func() { + cc.Title(fmt.Sprintf("m-iris — IRIS driver (contract %s)", caps.Contract)) + cc.KV( + [2]string{"engine", caps.Engine}, + [2]string{"transports", fmt.Sprint(caps.Transports)}, + ) + for _, axis := range []string{"lifecycle", "sync", "exec", "data", "cover", "admin", "meta"} { + if verbs, ok := caps.Axes[axis]; ok { + cc.Rule(axis) + fmt.Fprintln(cc.Stdout, " "+fmt.Sprint(verbs)) + } + } + }) +} + +// --- meta info --------------------------------------------------------------- + +type infoCmd struct{} + +// infoResult is the driver identity + the resolved engine target. Engine +// edition/version/namespaces come from a live probe (M1 lifecycle); until a +// transport is attached, info reports the static, no-engine facts so it is +// always safe to call (the first thing scaffolding runs). +type infoResult struct { + Driver string `json:"driver"` + Engine string `json:"engine"` + Contract string `json:"contract"` + Build string `json:"build"` + BaseURL string `json:"baseURL,omitempty"` + Namespace string `json:"namespace,omitempty"` +} + +func (infoCmd) Run(cc *clikit.Context, conn *config.Conn) error { + res := infoResult{ + Driver: "m-iris", + Engine: "iris", + Contract: driver.ContractVersion, + Build: clikit.Version, + BaseURL: conn.BaseURL, + Namespace: conn.Namespace, + } + return cc.Result(res, func() { + cc.Title("m-iris — driver info") + cc.KV( + [2]string{"driver", res.Driver}, + [2]string{"engine", res.Engine}, + [2]string{"contract", res.Contract}, + [2]string{"build", res.Build}, + [2]string{"namespace", res.Namespace}, + ) + }) +} diff --git a/meta_test.go b/meta_test.go new file mode 100644 index 0000000..fdee293 --- /dev/null +++ b/meta_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/vista-cloud-dev/m-iris/internal/config" + "github.com/vista-cloud-dev/m-iris/internal/driver" +) + +// TestCapsCommand_EmitsHonestDocument runs `meta caps` and asserts the envelope +// carries the live capability document — the thing m-cli reads before calling +// any optional verb. +func TestCapsCommand_EmitsHonestDocument(t *testing.T) { + cc, buf := jsonCtx() + if err := (capsCmd{}).Run(cc); err != nil { + t.Fatalf("caps: %v", err) + } + var env struct { + OK bool `json:"ok"` + Data driver.Caps `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("decode caps envelope: %v\n%s", err, buf.String()) + } + if !env.OK { + t.Error("caps envelope ok=false") + } + if env.Data.Engine != "iris" || env.Data.Contract != driver.ContractVersion { + t.Errorf("caps data = %+v, want engine=iris contract=%s", env.Data, driver.ContractVersion) + } + if !env.Data.Features["remote"] { + t.Error("caps must advertise the remote transport for IRIS") + } + // Honesty: every axis caps advertises must list at least one verb. + for axis, verbs := range env.Data.Axes { + if len(verbs) == 0 { + t.Errorf("axis %q advertised with no verbs", axis) + } + } +} + +// TestInfoCommand_ReportsIdentity runs `meta info` and asserts the driver +// identity + resolved target are reported without contacting an engine. +func TestInfoCommand_ReportsIdentity(t *testing.T) { + cc, buf := jsonCtx() + conn := &config.Conn{Namespace: "VISTA", BaseURL: "https://iris.example:52773/api/atelier/v1/"} + if err := (infoCmd{}).Run(cc, conn); err != nil { + t.Fatalf("info: %v", err) + } + var env struct { + Data infoResult `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("decode info envelope: %v\n%s", err, buf.String()) + } + if env.Data.Engine != "iris" || env.Data.Contract != driver.ContractVersion { + t.Errorf("info = %+v, want engine=iris contract=%s", env.Data, driver.ContractVersion) + } + if env.Data.Namespace != "VISTA" { + t.Errorf("info namespace = %q, want VISTA", env.Data.Namespace) + } +} diff --git a/push.go b/push.go index 025a29c..c2d8cc8 100644 --- a/push.go +++ b/push.go @@ -11,12 +11,12 @@ import ( "strings" "time" - "github.com/vista-cloud-dev/irissync/clikit" - "github.com/vista-cloud-dev/irissync/internal/atelier" - "github.com/vista-cloud-dev/irissync/internal/config" - "github.com/vista-cloud-dev/irissync/internal/lock" - "github.com/vista-cloud-dev/irissync/internal/manifest" - "github.com/vista-cloud-dev/irissync/internal/mirror" + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/atelier" + "github.com/vista-cloud-dev/m-iris/internal/config" + "github.com/vista-cloud-dev/m-iris/internal/lock" + "github.com/vista-cloud-dev/m-iris/internal/manifest" + "github.com/vista-cloud-dev/m-iris/internal/mirror" ) // pushCmd writes edited routines from the mirror back to IRIS — the sole DB @@ -73,7 +73,7 @@ func (c *pushCmd) Run(cc *clikit.Context, conn *config.Conn) error { } if man == nil { return clikit.Fail(clikit.ExitRuntime, "NO_MANIFEST", - "no manifest at "+layout.ManifestPath()+"; run 'irissync pull' first", "push requires a pulled mirror as its conflict-check basis") + "no manifest at "+layout.ManifestPath()+"; run 'm-iris sync pull' first", "push requires a pulled mirror as its conflict-check basis") } // Candidate routines: the manifest's docnames, filtered, that have a mirror diff --git a/push_test.go b/push_test.go index b45ea67..b91bae4 100644 --- a/push_test.go +++ b/push_test.go @@ -13,8 +13,8 @@ import ( "sync" "testing" - "github.com/vista-cloud-dev/irissync/clikit" - "github.com/vista-cloud-dev/irissync/internal/config" + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/config" ) // rwAtelier is a read+write fake Atelier server: it serves docnames, GET doc, From 9180e1b289fc7a85b447bbbd0ea2fe80fe564676 Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Thu, 4 Jun 2026 00:23:41 -0400 Subject: [PATCH 02/24] m-iris M1: lifecycle + health probes + doctor (remote/attach) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atelier root probe foundation, the lifecycle axis, and the doctor preflight — all on the remote (attach) transport, test-first against an httptest Atelier. - atelier.ServerInfo: GET /api/atelier/v1/ → version/api/namespaces; typed *HTTPError with IsUnauthorized/IsForbidden so 401 (bad credential) and 403 (no privilege) are distinct (risks C3, C7) - --transport local|docker|remote flag (default remote; only remote wired) - lifecycle axis (remote attach): status + --probe (CI gate, exit 0/6), wait --timeout (poll → exit 6), up/down/restart; provision/destroy report unsupported (exit 7) over Atelier — you cannot create/destroy a namespace there, so conformance runs in attached mode (risk B4) - meta doctor: typed matrix {name,ok,detail,fix}, exit 0/5/6 — reachable, auth, version (>= 2022.1), namespace, query-privilege (action/query SELECT 1 proves the runner privilege, risk C7), license (honestly not-probed on remote) - caps grown to advertise lifecycle + meta.doctor (still honest) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/m-iris-driver-status.md | 16 ++ doctor.go | 193 +++++++++++++++++ doctor_test.go | 122 +++++++++++ internal/atelier/client.go | 14 +- internal/atelier/httperr.go | 34 +++ internal/atelier/serverinfo.go | 39 ++++ internal/atelier/serverinfo_test.go | 66 ++++++ internal/config/config.go | 1 + internal/driver/caps.go | 5 +- internal/driver/testdata/caps.golden.json | 12 +- lifecycle.go | 239 ++++++++++++++++++++++ lifecycle_test.go | 98 +++++++++ main.go | 5 +- meta.go | 1 + 14 files changed, 835 insertions(+), 10 deletions(-) create mode 100644 doctor.go create mode 100644 doctor_test.go create mode 100644 internal/atelier/httperr.go create mode 100644 internal/atelier/serverinfo.go create mode 100644 internal/atelier/serverinfo_test.go create mode 100644 lifecycle.go create mode 100644 lifecycle_test.go diff --git a/docs/m-iris-driver-status.md b/docs/m-iris-driver-status.md index 0c1c336..6fec4a6 100644 --- a/docs/m-iris-driver-status.md +++ b/docs/m-iris-driver-status.md @@ -18,6 +18,22 @@ Legend: ☑ done · ◐ in progress · ☐ not started + `sync` (the regrouped source verbs). `caps` golden-tested and **honest** (advertises only what is wired; grows per milestone). +## M1 — lifecycle + health probes + doctor ◐ (remote done; local/docker pending) + +- ☑ `atelier.ServerInfo` — `GET /api/atelier/v1/` → version / api / namespaces; + typed `*HTTPError` with `IsUnauthorized`/`IsForbidden` (401 vs 403 distinct). +- ☑ `--transport local|docker|remote` flag (default `remote`; only remote wired). +- ☑ `lifecycle` axis (remote/attach): `status` (+`--probe` CI gate, exit 0/6), + `wait --timeout` (poll → exit 6 on timeout), `up` (verify+attach), `down` + (detach no-op), `restart`; `provision`/`destroy` report **unsupported (exit 7)** + over Atelier (risk B4). local/docker → not-implemented until M3 transports. +- ☑ `meta doctor` — typed matrix {name,ok,detail,fix}, exit 0/5/6: reachable, + auth (401/403), version (≥ 2022.1), namespace presence, **query-privilege** + (action/query SELECT 1 — the C7 runner-privilege proxy), license (honestly + not-probed on remote until M6). +- ☐ local/docker lifecycle (container / `iris start`/`iris stop`) — needs the + session-transport command seam (lands with M3 local+docker exec). + ## Remote spike (plan §5 task 8) — substrate built, real-engine green gated ◐ The remote substrate is the whole-cloth de-risking item (risk B2): Atelier has no diff --git a/doctor.go b/doctor.go new file mode 100644 index 0000000..7ba2688 --- /dev/null +++ b/doctor.go @@ -0,0 +1,193 @@ +package main + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/atelier" + "github.com/vista-cloud-dev/m-iris/internal/config" +) + +// minIRISYear is the oldest IRIS major (release year) m-iris supports. +const minIRISYear = 2022 + +// doctorCmd is `meta doctor` (driver-contract §5.7, plan §3): typed preflight +// diagnostics — the first thing CI and `m new` run. Each check is independent +// and self-describing ({name, ok, detail, fix}); the exit code lets CI branch: +// 0 all green, 6 engine-unreachable, 5 a check failed. +type doctorCmd struct{} + +type doctorCheck struct { + Name string `json:"name"` + OK bool `json:"ok"` + Detail string `json:"detail,omitempty"` + Fix string `json:"fix,omitempty"` +} + +type doctorResult struct { + Transport string `json:"transport"` + OK bool `json:"ok"` + Checks []doctorCheck `json:"checks"` +} + +func (doctorCmd) Run(cc *clikit.Context, conn *config.Conn) error { + if err := remoteOnly(conn); err != nil { + return err + } + res, exit := runDoctorRemote(context.Background(), conn) + if err := cc.Result(res, func() { renderDoctor(cc, res) }); err != nil { + return err + } + switch exit { + case clikit.ExitUnreachable: + return clikit.Fail(clikit.ExitUnreachable, "UNREACHABLE", + "engine unreachable — fix connectivity before other checks", "verify --base-url / network") + case clikit.ExitRuntime: + return clikit.Fail(clikit.ExitRuntime, "PREFLIGHT_FAILED", + "one or more preflight checks failed", "see the failing checks above") + } + return nil +} + +// runDoctorRemote runs the remote (Atelier) check matrix and returns the typed +// result plus the exit code (0 / 6 / 5). +func runDoctorRemote(ctx context.Context, conn *config.Conn) (doctorResult, int) { + res := doctorResult{Transport: "remote"} + add := func(name string, ok bool, detail, fix string) { + res.Checks = append(res.Checks, doctorCheck{Name: name, OK: ok, Detail: detail, Fix: fix}) + } + + client, err := remoteClient(conn) + if err != nil { + // Missing base-url/namespace is a usage error, surfaced directly. + add("config", false, err.Error(), "set --base-url and --namespace (or M_IRIS_* env)") + return finalize(res), clikit.ExitRuntime + } + + info, serr := client.ServerInfo(ctx) + switch { + case serr == nil: + add("reachable", true, "Atelier root responded", "") + add("auth", true, "credential accepted", "") + add(versionOK(info.Version)) + add(namespaceCheck(conn.Namespace, info.Namespaces)) + case atelier.IsUnauthorized(serr): + add("reachable", true, "server answered (HTTP 401)", "") + add("auth", false, "authentication failed (HTTP 401) — bad or missing credential", + "check --user/--password or --token-file") + skipDownstream(add, "401") + return finalize(res), clikit.ExitRuntime + case atelier.IsForbidden(serr): + add("reachable", true, "server answered (HTTP 403)", "") + add("auth", false, "authenticated but forbidden (HTTP 403) — credential lacks privilege", + "grant the user the Atelier/%Development role") + skipDownstream(add, "403") + return finalize(res), clikit.ExitRuntime + default: + add("reachable", false, "Atelier root unreachable: "+serr.Error(), + "verify --base-url, port 52773, and network/TLS") + skipDownstream(add, "unreachable") + return finalize(res), clikit.ExitUnreachable + } + + // query-privilege: the runner rides action/query, so prove that privilege now + // (risk C7) rather than discovering it at first exec. + add(queryPrivilegeCheck(ctx, client)) + + // license is not probeable over Atelier (no endpoint); it needs ObjectScript + // via the runner (M6). Report it honestly as not-probed rather than guessing. + add("license", true, "not probed on remote (no Atelier license endpoint; checked via the runner in M6)", "") + + return finalize(res), exitFor(res) +} + +func finalize(res doctorResult) doctorResult { + res.OK = true + for _, c := range res.Checks { + if !c.OK { + res.OK = false + break + } + } + return res +} + +func exitFor(res doctorResult) int { + for _, c := range res.Checks { + if !c.OK { + return clikit.ExitRuntime + } + } + return clikit.ExitOK +} + +// skipDownstream marks the checks that depend on a readable root as not-run. +func skipDownstream(add func(string, bool, string, string), why string) { + for _, n := range []string{"version", "namespace", "query-privilege", "license"} { + add(n, true, "skipped ("+why+")", "") + } +} + +var versionRe = regexp.MustCompile(`(\d{4})\.(\d+)`) + +func versionOK(version string) (string, bool, string, string) { + m := versionRe.FindStringSubmatch(version) + if m == nil { + return "version", true, "could not parse version " + strconv.Quote(version), "" + } + year, _ := strconv.Atoi(m[1]) + if year < minIRISYear { + return "version", false, + fmt.Sprintf("IRIS %s is older than the supported minimum %d.1", m[0], minIRISYear), + "upgrade IRIS to a supported release" + } + return "version", true, "IRIS " + m[0], "" +} + +func namespaceCheck(want string, namespaces []string) (string, bool, string, string) { + if len(namespaces) == 0 { + return "namespace", true, "server did not list namespaces; not verified", "" + } + for _, ns := range namespaces { + if strings.EqualFold(ns, want) { + return "namespace", true, "namespace " + want + " present", "" + } + } + return "namespace", false, "namespace " + want + " not found on the server", + "create the namespace, or target one of: " + strings.Join(namespaces, ", ") +} + +func queryPrivilegeCheck(ctx context.Context, client *atelier.Client) (string, bool, string, string) { + rows, err := client.Query(ctx, "SELECT 1 AS one") + switch { + case err == nil && len(rows) == 1 && rows[0]["one"] == "1": + return "query-privilege", true, "action/query (SQL) usable — the remote runner can run", "" + case atelier.IsForbidden(err): + return "query-privilege", false, "no SQL/action-query privilege (HTTP 403)", + "grant the user EXECUTE on the runner procedures / the %Development role" + case err != nil: + return "query-privilege", false, "action/query failed: " + err.Error(), + "verify the user can run SQL via Atelier" + default: + return "query-privilege", false, "action/query returned no result", "" + } +} + +func renderDoctor(cc *clikit.Context, res doctorResult) { + cc.Title("m-iris doctor — " + res.Transport) + for _, c := range res.Checks { + line := c.Name + ": " + c.Detail + if c.OK { + fmt.Fprintln(cc.Stdout, cc.Success(line)) + } else { + fmt.Fprintln(cc.Stdout, cc.Failure(line)) + if c.Fix != "" { + fmt.Fprintln(cc.Stdout, " fix: "+c.Fix) + } + } + } +} diff --git a/doctor_test.go b/doctor_test.go new file mode 100644 index 0000000..5b37e36 --- /dev/null +++ b/doctor_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/config" +) + +// doctorServer is a configurable fake Atelier for the doctor matrix: it can +// force an auth code on the root, and serves action/query (SELECT 1) for the +// privilege probe. +func doctorServer(rootCode int, namespaces []string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.Contains(r.URL.Path, "/action/query") { + w.Write([]byte(`{"status":{"errors":[]},"result":{"content":[{"one":"1"}]}}`)) + return + } + // root descriptor + if rootCode != 0 { + w.WriteHeader(rootCode) + return + } + nsJSON, _ := json.Marshal(namespaces) + w.Write([]byte(`{"status":{"errors":[]},"result":{"content":{` + + `"version":"IRIS for UNIX 2024.1","api":7,"namespaces":` + string(nsJSON) + `}}}`)) + })) +} + +func doctorConn(baseURL, ns string) *config.Conn { + return &config.Conn{Transport: "remote", BaseURL: baseURL + "/api/atelier/v1/", Namespace: ns} +} + +func decodeDoctor(t *testing.T, b []byte) doctorResult { + t.Helper() + var env struct { + Data doctorResult `json:"data"` + } + if err := json.Unmarshal(b, &env); err != nil { + t.Fatalf("decode doctor: %v\n%s", err, b) + } + return env.Data +} + +func checkByName(d doctorResult, name string) (doctorCheck, bool) { + for _, c := range d.Checks { + if c.Name == name { + return c, true + } + } + return doctorCheck{}, false +} + +// TestDoctor_AllGreen: a healthy reachable engine with the namespace present and +// SQL privilege → every check ok, exit 0. +func TestDoctor_AllGreen(t *testing.T) { + srv := doctorServer(0, []string{"%SYS", "USER", "VISTA"}) + defer srv.Close() + cc, buf := jsonCtx() + if err := (doctorCmd{}).Run(cc, doctorConn(srv.URL, "VISTA")); err != nil { + t.Fatalf("healthy doctor should exit 0: %v", err) + } + d := decodeDoctor(t, buf.Bytes()) + if !d.OK { + t.Errorf("doctor.OK = false, want true: %+v", d.Checks) + } + for _, c := range d.Checks { + if !c.OK { + t.Errorf("check %q failed unexpectedly: %s", c.Name, c.Detail) + } + } +} + +// TestDoctor_AuthFailExit5: reachable but 401 → reachable ok, auth fails, exit 5. +func TestDoctor_AuthFailExit5(t *testing.T) { + srv := doctorServer(http.StatusUnauthorized, nil) + defer srv.Close() + cc, buf := jsonCtx() + err := (doctorCmd{}).Run(cc, doctorConn(srv.URL, "VISTA")) + if code := exitOf(t, err); code != clikit.ExitRuntime { + t.Fatalf("auth-fail doctor exit = %d, want %d", code, clikit.ExitRuntime) + } + d := decodeDoctor(t, buf.Bytes()) + if c, _ := checkByName(d, "auth"); c.OK { + t.Error("auth check should fail on 401") + } + if c, _ := checkByName(d, "reachable"); !c.OK { + t.Error("reachable check should pass — the server answered") + } +} + +// TestDoctor_UnreachableExit6: connection refused → reachable fails, exit 6. +func TestDoctor_UnreachableExit6(t *testing.T) { + srv := doctorServer(0, nil) + srv.Close() // refuse connections + cc, _ := jsonCtx() + err := (doctorCmd{}).Run(cc, doctorConn(srv.URL, "VISTA")) + if code := exitOf(t, err); code != clikit.ExitUnreachable { + t.Fatalf("unreachable doctor exit = %d, want %d", code, clikit.ExitUnreachable) + } +} + +// TestDoctor_NamespaceMissingExit5: reachable+auth ok but the target namespace is +// absent → namespace check fails, exit 5. +func TestDoctor_NamespaceMissingExit5(t *testing.T) { + srv := doctorServer(0, []string{"%SYS", "USER"}) + defer srv.Close() + cc, buf := jsonCtx() + err := (doctorCmd{}).Run(cc, doctorConn(srv.URL, "VISTA")) + if code := exitOf(t, err); code != clikit.ExitRuntime { + t.Fatalf("missing-namespace doctor exit = %d, want %d", code, clikit.ExitRuntime) + } + d := decodeDoctor(t, buf.Bytes()) + if c, _ := checkByName(d, "namespace"); c.OK { + t.Error("namespace check should fail when VISTA is absent") + } +} diff --git a/internal/atelier/client.go b/internal/atelier/client.go index 1d26a97..35cbd0b 100644 --- a/internal/atelier/client.go +++ b/internal/atelier/client.go @@ -165,27 +165,29 @@ func (c *Client) do(ctx context.Context, method string, u *url.URL, body []byte, if err != nil { return fmt.Errorf("atelier: read response from %s: %w", u.Path, err) } - if resp.StatusCode == http.StatusUnauthorized { - return fmt.Errorf("atelier: authentication failed (HTTP 401) for %s", u.Path) + // Auth failures carry no useful envelope; surface them as typed HTTPErrors so + // doctor/lifecycle can tell 401 (bad credential) from 403 (no privilege). + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return &HTTPError{Status: resp.StatusCode, Method: method, Path: u.Path} } // Atelier returns its envelope (with status.errors) even on 4xx/5xx, so try - // to decode before falling back to a bare HTTP-status error. + // to decode before falling back to a typed HTTP-status error. if len(data) > 0 { if err := json.Unmarshal(data, out); err != nil { if resp.StatusCode >= 400 { - return fmt.Errorf("atelier: %s %s: HTTP %d", method, u.Path, resp.StatusCode) + return &HTTPError{Status: resp.StatusCode, Method: method, Path: u.Path} } return fmt.Errorf("atelier: decode response from %s: %w", u.Path, err) } } else if resp.StatusCode >= 400 { - return fmt.Errorf("atelier: %s %s: HTTP %d", method, u.Path, resp.StatusCode) + return &HTTPError{Status: resp.StatusCode, Method: method, Path: u.Path} } if err := out.Status.firstError(); err != nil { return fmt.Errorf("atelier: %s: %w", u.Path, err) } if resp.StatusCode >= 400 { - return fmt.Errorf("atelier: %s %s: HTTP %d", method, u.Path, resp.StatusCode) + return &HTTPError{Status: resp.StatusCode, Method: method, Path: u.Path} } return nil } diff --git a/internal/atelier/httperr.go b/internal/atelier/httperr.go new file mode 100644 index 0000000..af493e5 --- /dev/null +++ b/internal/atelier/httperr.go @@ -0,0 +1,34 @@ +package atelier + +import ( + "errors" + "fmt" + "net/http" +) + +// HTTPError is a non-2xx Atelier response surfaced as a typed error, so callers +// (doctor, lifecycle) can branch on the status — distinguishing "bad credential" +// (401) from "no privilege" (403) from "unreachable/other" (risks C3, C7) — +// instead of string-matching a message. +type HTTPError struct { + Status int + Method string + Path string +} + +func (e *HTTPError) Error() string { + return fmt.Sprintf("atelier: %s %s: HTTP %d", e.Method, e.Path, e.Status) +} + +// IsUnauthorized reports whether err is (or wraps) a 401 — authentication failed +// (missing/invalid credential). +func IsUnauthorized(err error) bool { return hasStatus(err, http.StatusUnauthorized) } + +// IsForbidden reports whether err is (or wraps) a 403 — authenticated but the +// credential lacks the required privilege. +func IsForbidden(err error) bool { return hasStatus(err, http.StatusForbidden) } + +func hasStatus(err error, status int) bool { + var he *HTTPError + return errors.As(err, &he) && he.Status == status +} diff --git a/internal/atelier/serverinfo.go b/internal/atelier/serverinfo.go new file mode 100644 index 0000000..4c978f5 --- /dev/null +++ b/internal/atelier/serverinfo.go @@ -0,0 +1,39 @@ +package atelier + +import ( + "context" + "encoding/json" + "fmt" +) + +// ServerInfo is the Atelier root probe result (GET /api/atelier/v1/): the engine +// version, the Atelier API level, and the namespaces the credential can see. It +// is the substrate for lifecycle status / health probes / doctor / meta info on +// the remote transport. +type ServerInfo struct { + Version string `json:"version"` + API int `json:"api"` + Namespaces []string `json:"namespaces,omitempty"` +} + +// ServerInfo issues GET against the Atelier base root and decodes the server +// descriptor. A 401/403 comes back as a typed *HTTPError (see IsUnauthorized / +// IsForbidden) so doctor can report auth state precisely. +func (c *Client) ServerInfo(ctx context.Context) (*ServerInfo, error) { + u := *c.base // the base already ends in /api/atelier/v1/ + + var env Envelope + if err := c.get(ctx, &u, &env); err != nil { + return nil, err + } + if len(env.Result) == 0 { + return nil, fmt.Errorf("atelier: empty root response") + } + var wrapped struct { + Content ServerInfo `json:"content"` + } + if err := json.Unmarshal(env.Result, &wrapped); err != nil { + return nil, fmt.Errorf("atelier: decode root response: %w", err) + } + return &wrapped.Content, nil +} diff --git a/internal/atelier/serverinfo_test.go b/internal/atelier/serverinfo_test.go new file mode 100644 index 0000000..8b97a63 --- /dev/null +++ b/internal/atelier/serverinfo_test.go @@ -0,0 +1,66 @@ +package atelier + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// TestServerInfo_RoundTrip drives the Atelier root probe (GET /api/atelier/v1/), +// the foundation of lifecycle status / health / doctor: it returns the engine +// version + the namespaces the credential can see. +func TestServerInfo_RoundTrip(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":{"errors":[]},"result":{"content":{` + + `"version":"IRIS for UNIX (Ubuntu Server LTS) 2024.1","api":7,` + + `"namespaces":["%SYS","USER","VISTA"]}}}`)) + })) + defer srv.Close() + + c, _ := New(Config{BaseURL: srv.URL + "/api/atelier/v1/", Namespace: "USER"}) + info, err := c.ServerInfo(context.Background()) + if err != nil { + t.Fatalf("ServerInfo: %v", err) + } + if gotPath != "/api/atelier/v1/" { + t.Errorf("path = %q, want /api/atelier/v1/", gotPath) + } + if info.Version != "IRIS for UNIX (Ubuntu Server LTS) 2024.1" || info.API != 7 { + t.Errorf("info = %+v", info) + } + if len(info.Namespaces) != 3 || info.Namespaces[2] != "VISTA" { + t.Errorf("namespaces = %v", info.Namespaces) + } +} + +// TestServerInfo_AuthDistinct maps 401 and 403 to distinct typed errors so +// doctor can tell "bad credentials" from "no privilege" (risks C3, C7). +func TestServerInfo_AuthDistinct(t *testing.T) { + for _, tc := range []struct { + code int + wantUnauth, wantForbid bool + }{ + {http.StatusUnauthorized, true, false}, + {http.StatusForbidden, false, true}, + } { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.code) + })) + c, _ := New(Config{BaseURL: srv.URL + "/api/atelier/v1/", Namespace: "USER"}) + _, err := c.ServerInfo(context.Background()) + srv.Close() + if err == nil { + t.Fatalf("HTTP %d: expected an error", tc.code) + } + if IsUnauthorized(err) != tc.wantUnauth { + t.Errorf("HTTP %d: IsUnauthorized = %v, want %v", tc.code, IsUnauthorized(err), tc.wantUnauth) + } + if IsForbidden(err) != tc.wantForbid { + t.Errorf("HTTP %d: IsForbidden = %v, want %v", tc.code, IsForbidden(err), tc.wantForbid) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 64cf9bb..86c1da3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ import ( // every subcommand, and bound (via kong.Bind) so command Run methods receive a // *Conn. Flags win over env; defaults fill the rest. type Conn struct { + Transport string `env:"M_IRIS_TRANSPORT" enum:"local,docker,remote" default:"remote" help:"Engine transport: local | docker | remote (Atelier REST). Only remote is wired today."` BaseURL string `name:"base-url" env:"M_IRIS_BASE_URL" help:"Atelier base URL, e.g. https://host:52773/api/atelier/v1/" placeholder:"URL"` Instance string `env:"M_IRIS_INSTANCE" help:"Instance label used in the mirror path." placeholder:"NAME"` Namespace string `env:"M_IRIS_NAMESPACE" help:"IRIS namespace to liberate." placeholder:"NS"` diff --git a/internal/driver/caps.go b/internal/driver/caps.go index 4439ef4..a2881f8 100644 --- a/internal/driver/caps.go +++ b/internal/driver/caps.go @@ -36,8 +36,11 @@ func capsDoc() Caps { Transports: []string{"local", "docker", "remote"}, Axes: map[string][]string{ // M0 — meta + the existing irissync source verbs, regrouped under sync. - "meta": {"caps", "version", "info", "schema"}, + "meta": {"caps", "version", "info", "schema", "doctor"}, "sync": {"list", "pull", "status", "verify", "push", "deploy"}, + // M1 — lifecycle + health probes. provision/destroy are advertised but + // report unsupported (exit 7) on the remote transport (risk B4). + "lifecycle": {"up", "down", "restart", "status", "wait", "provision", "destroy"}, }, Features: map[string]bool{ "remote": true, // IRIS reaches over Atelier REST diff --git a/internal/driver/testdata/caps.golden.json b/internal/driver/testdata/caps.golden.json index c64ea67..b4fc42b 100644 --- a/internal/driver/testdata/caps.golden.json +++ b/internal/driver/testdata/caps.golden.json @@ -7,11 +7,21 @@ "remote" ], "axes": { + "lifecycle": [ + "up", + "down", + "restart", + "status", + "wait", + "provision", + "destroy" + ], "meta": [ "caps", "version", "info", - "schema" + "schema", + "doctor" ], "sync": [ "list", diff --git a/lifecycle.go b/lifecycle.go new file mode 100644 index 0000000..b3aa5be --- /dev/null +++ b/lifecycle.go @@ -0,0 +1,239 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/atelier" + "github.com/vista-cloud-dev/m-iris/internal/config" +) + +// lifecycleCmd is the lifecycle axis (driver-contract §5.1): manage the engine +// instance. On the IRIS `remote` transport the driver ATTACHES to an existing +// namespace and manages routines only — you cannot create or destroy a namespace +// over Atelier (risk B4) — so provision/destroy report unsupported (exit 7) and +// conformance runs in attached mode. up verifies reachability; down/restart are +// no-ops; status/wait drive health off the Atelier root probe. The docker/local +// strategies (container / `iris start`) land with the M3 session transports. +type lifecycleCmd struct { + Up lifeUpCmd `cmd:"" help:"Bring the engine into a usable state (remote: verify reachable + attach)."` + Down lifeDownCmd `cmd:"" help:"Stop the engine (remote: no-op — the server is not ours to stop)."` + Restart lifeRestartCmd `cmd:"" help:"Restart the engine (remote: re-verify reachable)."` + Status lifeStatusCmd `cmd:"" help:"Report running/healthy/version/namespaces; --probe for a terse CI readiness gate."` + Wait lifeWaitCmd `cmd:"" help:"Block until the engine is healthy or --timeout elapses (exit 6 on timeout)."` + Provision lifeProvisionCmd `cmd:"" help:"Create an instance/namespace (remote: unsupported over Atelier, exit 7)."` + Destroy lifeDestroyCmd `cmd:"" help:"Remove an instance/namespace (remote: unsupported over Atelier, exit 7)."` +} + +// lifecycleStatus is the status/probe payload (driver-contract §5.1). +type lifecycleStatus struct { + Transport string `json:"transport"` + Running bool `json:"running"` + Healthy bool `json:"healthy"` + Version string `json:"version,omitempty"` + Namespaces []string `json:"namespaces,omitempty"` + LatencyMs int64 `json:"latencyMs"` + Endpoint string `json:"endpoint,omitempty"` +} + +// remoteOnly returns a not-yet-implemented error for local/docker (only remote +// is wired today) and nil for remote. An empty transport defaults to remote. +func remoteOnly(conn *config.Conn) error { + switch conn.Transport { + case "", "remote": + return nil + default: + return clikit.Fail(clikit.ExitRuntime, "TRANSPORT_NOT_IMPLEMENTED", + fmt.Sprintf("transport %q is not yet wired in m-iris (only remote today)", conn.Transport), + "use --transport remote, or wait for the M3 local+docker session transports") + } +} + +// remoteClient builds the Atelier client for the remote transport. +func remoteClient(conn *config.Conn) (*atelier.Client, error) { + if err := conn.Validate(config.Need{Network: true}); err != nil { + return nil, usageErr(err) + } + acfg, err := conn.Atelier() + if err != nil { + return nil, usageErr(err) + } + c, err := atelier.New(acfg) + if err != nil { + return nil, runtimeErr(err) + } + return c, nil +} + +// probeRemote probes the Atelier root and classifies the result: reachable+ok, +// reachable-but-auth-failed (server answered 401/403), or unreachable. +func probeRemote(ctx context.Context, conn *config.Conn) (lifecycleStatus, error) { + client, err := remoteClient(conn) + if err != nil { + return lifecycleStatus{}, err + } + st := lifecycleStatus{Transport: "remote", Endpoint: conn.BaseURL} + start := time.Now() + info, err := client.ServerInfo(ctx) + st.LatencyMs = time.Since(start).Milliseconds() + switch { + case err == nil: + st.Running, st.Healthy = true, true + st.Version, st.Namespaces = info.Version, info.Namespaces + case atelier.IsUnauthorized(err), atelier.IsForbidden(err): + st.Running, st.Healthy = true, false // the server answered; the credential failed + default: + st.Running, st.Healthy = false, false + } + return st, nil +} + +func engineUnreachable(msg string) error { + return clikit.Fail(clikit.ExitUnreachable, "UNREACHABLE", msg, + "verify --base-url and credentials; run `m-iris meta doctor`") +} + +// --- lifecycle status / --probe --------------------------------------------- + +type lifeStatusCmd struct { + Probe bool `help:"Terse readiness gate: {running, healthy, latencyMs}; exit 0 healthy, 6 not ready."` +} + +func (c lifeStatusCmd) Run(cc *clikit.Context, conn *config.Conn) error { + if err := remoteOnly(conn); err != nil { + return err + } + st, err := probeRemote(context.Background(), conn) + if err != nil { + return err + } + if c.Probe { + terse := lifecycleStatus{Transport: st.Transport, Running: st.Running, Healthy: st.Healthy, LatencyMs: st.LatencyMs} + if rerr := cc.Result(terse, func() { + cc.KV([2]string{"healthy", fmt.Sprint(terse.Healthy)}, [2]string{"latencyMs", fmt.Sprint(terse.LatencyMs)}) + }); rerr != nil { + return rerr + } + if !st.Healthy { + return clikit.Fail(clikit.ExitUnreachable, "NOT_READY", "engine not ready", "run `m-iris meta doctor` for the cause") + } + return nil + } + return cc.Result(st, func() { + cc.Title("engine status — " + st.Transport) + cc.KV( + [2]string{"running", fmt.Sprint(st.Running)}, + [2]string{"healthy", fmt.Sprint(st.Healthy)}, + [2]string{"version", st.Version}, + [2]string{"namespaces", fmt.Sprint(st.Namespaces)}, + ) + }) +} + +// --- lifecycle up / down / restart ------------------------------------------ + +type lifeUpCmd struct{} + +type lifeStateResult struct { + State string `json:"state"` + Endpoint string `json:"endpoint,omitempty"` +} + +func (lifeUpCmd) Run(cc *clikit.Context, conn *config.Conn) error { + if err := remoteOnly(conn); err != nil { + return err + } + st, err := probeRemote(context.Background(), conn) + if err != nil { + return err + } + if !st.Running { + return engineUnreachable("up: engine is not reachable to attach to") + } + return cc.Result(lifeStateResult{State: "attached", Endpoint: conn.BaseURL}, func() { + fmt.Fprintln(cc.Stdout, cc.Success("attached to "+conn.BaseURL)) + }) +} + +type lifeDownCmd struct{} + +func (lifeDownCmd) Run(cc *clikit.Context, conn *config.Conn) error { + if err := remoteOnly(conn); err != nil { + return err + } + // The remote server is not ours to stop; down is a no-op that just detaches. + return cc.Result(lifeStateResult{State: "detached"}, func() { + fmt.Fprintln(cc.Stdout, "detached (remote engine left running)") + }) +} + +type lifeRestartCmd struct{} + +func (lifeRestartCmd) Run(cc *clikit.Context, conn *config.Conn) error { + return (lifeUpCmd{}).Run(cc, conn) +} + +// --- lifecycle wait ---------------------------------------------------------- + +type lifeWaitCmd struct { + Timeout int `default:"60" help:"Seconds to wait for readiness before giving up (exit 6)."` +} + +func (c *lifeWaitCmd) Run(cc *clikit.Context, conn *config.Conn) error { + if err := remoteOnly(conn); err != nil { + return err + } + deadline := time.Now().Add(time.Duration(c.Timeout) * time.Second) + const poll = 100 * time.Millisecond + var st lifecycleStatus + for { + var err error + st, err = probeRemote(context.Background(), conn) + if err != nil { + return err + } + if st.Healthy { + return cc.Result(st, func() { + fmt.Fprintln(cc.Stdout, cc.Success(fmt.Sprintf("healthy in %dms", st.LatencyMs))) + }) + } + if !time.Now().Before(deadline) { + break + } + time.Sleep(poll) + } + _ = cc.Result(st, nil) + return clikit.Fail(clikit.ExitUnreachable, "WAIT_TIMEOUT", + fmt.Sprintf("engine not healthy after %ds", c.Timeout), "check the engine is up and reachable") +} + +// --- lifecycle provision / destroy (unsupported on remote) ------------------ + +type lifeProvisionCmd struct{} + +func (lifeProvisionCmd) Run(cc *clikit.Context, conn *config.Conn) error { + return unsupportedOnRemote(conn, "provision", + "create the namespace on the server, then attach with --transport remote") +} + +type lifeDestroyCmd struct{} + +func (lifeDestroyCmd) Run(cc *clikit.Context, conn *config.Conn) error { + return unsupportedOnRemote(conn, "destroy", + "drop the namespace on the server directly; m-iris remote only manages routines") +} + +// unsupportedOnRemote reports exit 7 for provision/destroy on remote (a namespace +// cannot be created or removed over Atelier — risk B4) and not-implemented for +// local/docker until those transports land. +func unsupportedOnRemote(conn *config.Conn, verb, hint string) error { + switch conn.Transport { + case "", "remote": + return clikit.Fail(clikit.ExitUnsupported, "UNSUPPORTED_ON_REMOTE", + verb+" is impossible over Atelier — remote attaches to an existing namespace", hint) + default: + return remoteOnly(conn) + } +} diff --git a/lifecycle_test.go b/lifecycle_test.go new file mode 100644 index 0000000..69e49ec --- /dev/null +++ b/lifecycle_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/config" +) + +// rootServer serves the Atelier root descriptor (the health/status substrate). +func rootServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":{"errors":[]},"result":{"content":{` + + `"version":"IRIS for UNIX 2024.1","api":7,"namespaces":["%SYS","USER","VISTA"]}}}`)) + })) +} + +func remoteConn(baseURL string) *config.Conn { + return &config.Conn{Transport: "remote", BaseURL: baseURL + "/api/atelier/v1/", Namespace: "VISTA"} +} + +// TestLifecycleStatus_RemoteHealthy reports running/healthy + version + +// namespaces from the Atelier root on the remote (attach) transport. +func TestLifecycleStatus_RemoteHealthy(t *testing.T) { + srv := rootServer() + defer srv.Close() + + cc, buf := jsonCtx() + if err := (lifeStatusCmd{}).Run(cc, remoteConn(srv.URL)); err != nil { + t.Fatalf("status: %v", err) + } + var env struct { + Data lifecycleStatus `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, buf.String()) + } + d := env.Data + if !d.Running || !d.Healthy || d.Version != "IRIS for UNIX 2024.1" { + t.Errorf("status = %+v, want running+healthy with version", d) + } + if len(d.Namespaces) != 3 { + t.Errorf("namespaces = %v, want 3", d.Namespaces) + } +} + +// TestLifecycleProbe_ExitCodes: --probe is the CI gate — exit 0 healthy, exit 6 +// unreachable. The envelope is emitted either way. +func TestLifecycleProbe_ExitCodes(t *testing.T) { + srv := rootServer() + cc, _ := jsonCtx() + if err := (lifeStatusCmd{Probe: true}).Run(cc, remoteConn(srv.URL)); err != nil { + t.Fatalf("healthy probe should exit 0: %v", err) + } + srv.Close() // now unreachable + + cc, _ = jsonCtx() + err := (lifeStatusCmd{Probe: true}).Run(cc, remoteConn(srv.URL)) + if code := exitOf(t, err); code != clikit.ExitUnreachable { + t.Fatalf("unreachable probe exit = %d, want %d", code, clikit.ExitUnreachable) + } +} + +// TestLifecycleProvision_UnsupportedOnRemote: you cannot create a namespace over +// Atelier (risk B4) — provision/destroy must report unsupported (exit 7) so +// conformance runs in attached mode there. +func TestLifecycleProvision_UnsupportedOnRemote(t *testing.T) { + cc, _ := jsonCtx() + err := (lifeProvisionCmd{}).Run(cc, remoteConn("http://unused")) + if code := exitOf(t, err); code != clikit.ExitUnsupported { + t.Fatalf("provision on remote exit = %d, want %d (unsupported)", code, clikit.ExitUnsupported) + } +} + +// TestLifecycleWait_BecomesHealthy returns once the root probe is healthy. +func TestLifecycleWait_BecomesHealthy(t *testing.T) { + srv := rootServer() + defer srv.Close() + cc, _ := jsonCtx() + if err := (&lifeWaitCmd{Timeout: 2}).Run(cc, remoteConn(srv.URL)); err != nil { + t.Fatalf("wait on a healthy engine: %v", err) + } +} + +// TestLifecycleWait_TimesOut exits 6 when the engine never becomes healthy. +func TestLifecycleWait_TimesOut(t *testing.T) { + srv := rootServer() + srv.Close() // never reachable + cc, _ := jsonCtx() + err := (&lifeWaitCmd{Timeout: 1}).Run(cc, remoteConn(srv.URL)) + if code := exitOf(t, err); code != clikit.ExitUnreachable { + t.Fatalf("wait timeout exit = %d, want %d", code, clikit.ExitUnreachable) + } +} diff --git a/main.go b/main.go index 288c1b9..43f6f19 100644 --- a/main.go +++ b/main.go @@ -43,8 +43,9 @@ type CLI struct { clikit.Globals config.Conn - Meta metaCmd `cmd:"" help:"Introspection + power tools: caps / info / version / schema."` - Sync syncCmd `cmd:"" help:"Source axis: routine source ↔ instance (list / pull / status / verify / push / deploy)."` + Meta metaCmd `cmd:"" help:"Introspection + power tools: caps / info / version / schema."` + Lifecycle lifecycleCmd `cmd:"" help:"Manage the engine instance: up / down / restart / status / wait / provision / destroy."` + Sync syncCmd `cmd:"" help:"Source axis: routine source ↔ instance (list / pull / status / verify / push / deploy)."` InstallCompletions kongplete.InstallCompletions `cmd:"" help:"Install shell tab-completions."` } diff --git a/meta.go b/meta.go index 414dc26..e4117d8 100644 --- a/meta.go +++ b/meta.go @@ -14,6 +14,7 @@ import ( type metaCmd struct { Caps capsCmd `cmd:"" help:"Emit the capability document (axes, transports, features) m-cli reads before calling optional verbs."` Info infoCmd `cmd:"" help:"Driver identity + resolved engine target (edition/version filled by the M1 probe)."` + Doctor doctorCmd `cmd:"" help:"Typed preflight: reachable / auth / version / namespace / query-privilege / license (exit 0/5/6)."` Version clikit.VersionCmd `cmd:"" help:"Show version and build info."` Schema clikit.SchemaCmd `cmd:"" help:"Emit the command/flag tree as JSON (agent discovery)."` } From 2d13d466d88dd86133f91c406d6f6d231be963b3 Mon Sep 17 00:00:00 2001 From: Rafael Date: Thu, 4 Jun 2026 06:17:16 -0400 Subject: [PATCH 03/24] m-iris: switch onto m-driver-sdk (Phase-0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the extracted shared SDK; the frozen verb-level Transport now lives in m-driver-sdk (package mdriver) instead of internal/driver. - Delete internal/driver/{transport,fake,transport_test}.go (moved to the SDK, incl. FakeTransport). - caps.go: Caps map→mdriver struct (honest set unchanged); regenerate golden; add UPDATE_GOLDEN support. meta.go/meta_test.go use Axes.Wired() + the struct Features. - internal/remote (Atelier-SQL runner substrate) retargeted to mdriver types; readEngineError now returns *mdriver.EngineError (behavior unchanged). - go.mod: require + local replace ../m-driver-sdk. Reconciliation that landed in the SDK: SetGlobal kept, Compile dropped, field-based Exec, GlobalNode tree, EngineError in the SDK. All race/vet/gofmt clean; the gated TestRemoteSpike_RealEngine remains gated. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 3 + internal/driver/caps.go | 66 ++++++--------- internal/driver/caps_test.go | 13 ++- internal/driver/fake.go | 69 ---------------- internal/driver/testdata/caps.golden.json | 18 ++--- internal/driver/transport.go | 97 ----------------------- internal/driver/transport_test.go | 60 -------------- internal/remote/integration_test.go | 10 +-- internal/remote/remote.go | 55 +++++++------ internal/remote/remote_test.go | 10 +-- meta.go | 11 ++- meta_test.go | 22 ++--- 12 files changed, 99 insertions(+), 335 deletions(-) delete mode 100644 internal/driver/fake.go delete mode 100644 internal/driver/transport.go delete mode 100644 internal/driver/transport_test.go diff --git a/go.mod b/go.mod index f81eac5..e852adc 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,9 @@ require ( github.com/posener/complete v1.2.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect + github.com/vista-cloud-dev/m-driver-sdk v0.0.0 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.44.0 // indirect ) + +replace github.com/vista-cloud-dev/m-driver-sdk => ../m-driver-sdk diff --git a/internal/driver/caps.go b/internal/driver/caps.go index a2881f8..8118674 100644 --- a/internal/driver/caps.go +++ b/internal/driver/caps.go @@ -1,56 +1,38 @@ -// Package driver holds the vendor-neutral m engine-driver contract types -// (driver-contract.md v1.0) as m-iris implements them: the capability document, -// the verb-level Transport seam (local/docker/remote), and the structured -// engine-error shape. These are vendored thin locally until the shared -// m-driver-sdk is extracted at the Phase-0 checkpoint (kickoff-prompts.md -// "Coordination model"); m-iris then depends on the SDK and deletes the copies. -// -// Nothing here knows about m-cli — the driver implements vendor logic only, -// against the frozen contract. +// Package driver holds the m-iris vendor logic against the neutral engine-driver +// contract: the capability document and the IRIS-specific realization of the +// shared verb-level Transport. The contract shapes and the Transport interface +// live in the shared SDK (github.com/vista-cloud-dev/m-driver-sdk); this package +// knows nothing of m-cli. package driver -// ContractVersion is the driver-contract major.minor this binary implements and -// advertises in caps. m-cli refuses a driver whose major it does not understand. -const ContractVersion = "1.0" +import mdriver "github.com/vista-cloud-dev/m-driver-sdk" // Caps is the capability document (driver-contract.md §4). m-cli calls // `m-iris meta caps` before optional verbs and adapts to exactly what is // advertised; calling an unadvertised verb yields exit 7 (unsupported). -type Caps struct { - Engine string `json:"engine"` - Contract string `json:"contract"` - Transports []string `json:"transports"` - Axes map[string][]string `json:"axes"` - Features map[string]bool `json:"features"` -} - -// caps is the live document. It is HONEST by construction: it lists only the -// axes/verbs that are actually wired in this build, and grows milestone by -// milestone (M1 lifecycle, M3 exec, M4 data, M5 cover, M6 admin, M7 native). -// Conformance asserts advertised == implemented, so do not list a verb here -// before its command exists. -func capsDoc() Caps { - return Caps{ +// +// It is HONEST by construction: it lists only the axes/verbs actually wired in +// this build, and grows milestone by milestone (M1 lifecycle, M3 exec, M4 data, +// M5 cover, M6 admin, M7 native). Conformance asserts advertised == implemented, +// so do not list a verb here before its command exists. +func CapsDoc() mdriver.Caps { + return mdriver.Caps{ Engine: "iris", - Contract: ContractVersion, - Transports: []string{"local", "docker", "remote"}, - Axes: map[string][]string{ + Contract: mdriver.ContractVersion, + Transports: []string{mdriver.TransportLocal, mdriver.TransportDocker, mdriver.TransportRemote}, + Axes: mdriver.Axes{ // M0 — meta + the existing irissync source verbs, regrouped under sync. - "meta": {"caps", "version", "info", "schema", "doctor"}, - "sync": {"list", "pull", "status", "verify", "push", "deploy"}, + Meta: []string{"caps", "version", "info", "schema", "doctor"}, + Sync: []string{"list", "pull", "status", "verify", "push", "deploy"}, // M1 — lifecycle + health probes. provision/destroy are advertised but // report unsupported (exit 7) on the remote transport (risk B4). - "lifecycle": {"up", "down", "restart", "status", "wait", "provision", "destroy"}, + Lifecycle: []string{"up", "down", "restart", "status", "wait", "provision", "destroy"}, }, - Features: map[string]bool{ - "remote": true, // IRIS reaches over Atelier REST - "prune": true, // sync deploy --prune true-sync - "ephemeralPrefix": true, // exec --prefix zzt staging - "snapshot": false, // lifecycle snapshot/rollback — not yet (roadmap §10) + Features: mdriver.Features{ + Remote: true, // IRIS reaches over Atelier REST + Prune: true, // sync deploy --prune true-sync + EphemeralPrefix: true, // exec --prefix zzt staging + Snapshot: false, // lifecycle snapshot/rollback — not yet (roadmap §10) }, } } - -// CapsDoc returns the live capability document. Exported as a function (not a -// var) so the slices/maps cannot be mutated by a caller. -func CapsDoc() Caps { return capsDoc() } diff --git a/internal/driver/caps_test.go b/internal/driver/caps_test.go index 4c82323..5f08c71 100644 --- a/internal/driver/caps_test.go +++ b/internal/driver/caps_test.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "testing" + + mdriver "github.com/vista-cloud-dev/m-driver-sdk" ) // TestCaps_Golden pins the m-iris capability document (driver-contract.md §4). @@ -19,6 +21,11 @@ func TestCaps_Golden(t *testing.T) { got = append(got, '\n') golden := filepath.Join("testdata", "caps.golden.json") + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.WriteFile(golden, got, 0o644); err != nil { + t.Fatalf("update golden: %v", err) + } + } want, err := os.ReadFile(golden) if err != nil { t.Fatalf("read golden: %v", err) @@ -35,11 +42,11 @@ func TestCaps_Invariants(t *testing.T) { if c.Engine != "iris" { t.Errorf("engine = %q, want iris", c.Engine) } - if c.Contract != ContractVersion { - t.Errorf("contract = %q, want %q", c.Contract, ContractVersion) + if c.Contract != mdriver.ContractVersion { + t.Errorf("contract = %q, want %q", c.Contract, mdriver.ContractVersion) } // IRIS is the only engine with a remote transport (Atelier REST). - if !c.Features["remote"] { + if !c.Features.Remote { t.Error("features.remote must be true for IRIS") } wantTransports := map[string]bool{"local": true, "docker": true, "remote": true} diff --git a/internal/driver/fake.go b/internal/driver/fake.go deleted file mode 100644 index 9ae3d9d..0000000 --- a/internal/driver/fake.go +++ /dev/null @@ -1,69 +0,0 @@ -package driver - -import "context" - -// FakeTransport is the injected Transport for unit tests (no engine). It records -// every call and returns canned results, so a command's behavior — argv shape, -// envelope, engineError mapping — is asserted without a real IRIS. The real -// transports appear only in the gated integration tier (driver-plan §1, "No -// hidden engine in unit tests"). -// -// Set the *Fn fields to script behavior; unset verbs return a zero result. -// Calls records an ordered trace for argv/stdin assertions. -type FakeTransport struct { - HealthFn func(ctx context.Context) (Health, error) - LoadFn func(ctx context.Context, req LoadRequest) (LoadResult, error) - ExecFn func(ctx context.Context, req ExecRequest) (ExecResult, error) - ReadGlobalFn func(ctx context.Context, req GlobalRef) (GlobalNode, error) - SetGlobalFn func(ctx context.Context, ref, value string) error - - Calls []FakeCall -} - -// FakeCall is one recorded interaction. -type FakeCall struct { - Verb string - Req any -} - -var _ Transport = (*FakeTransport)(nil) - -func (f *FakeTransport) Health(ctx context.Context) (Health, error) { - f.Calls = append(f.Calls, FakeCall{Verb: "Health"}) - if f.HealthFn != nil { - return f.HealthFn(ctx) - } - return Health{}, nil -} - -func (f *FakeTransport) Load(ctx context.Context, req LoadRequest) (LoadResult, error) { - f.Calls = append(f.Calls, FakeCall{Verb: "Load", Req: req}) - if f.LoadFn != nil { - return f.LoadFn(ctx, req) - } - return LoadResult{}, nil -} - -func (f *FakeTransport) Exec(ctx context.Context, req ExecRequest) (ExecResult, error) { - f.Calls = append(f.Calls, FakeCall{Verb: "Exec", Req: req}) - if f.ExecFn != nil { - return f.ExecFn(ctx, req) - } - return ExecResult{}, nil -} - -func (f *FakeTransport) ReadGlobal(ctx context.Context, req GlobalRef) (GlobalNode, error) { - f.Calls = append(f.Calls, FakeCall{Verb: "ReadGlobal", Req: req}) - if f.ReadGlobalFn != nil { - return f.ReadGlobalFn(ctx, req) - } - return GlobalNode{}, nil -} - -func (f *FakeTransport) SetGlobal(ctx context.Context, ref, value string) error { - f.Calls = append(f.Calls, FakeCall{Verb: "SetGlobal", Req: [2]string{ref, value}}) - if f.SetGlobalFn != nil { - return f.SetGlobalFn(ctx, ref, value) - } - return nil -} diff --git a/internal/driver/testdata/caps.golden.json b/internal/driver/testdata/caps.golden.json index b4fc42b..727c65a 100644 --- a/internal/driver/testdata/caps.golden.json +++ b/internal/driver/testdata/caps.golden.json @@ -16,13 +16,6 @@ "provision", "destroy" ], - "meta": [ - "caps", - "version", - "info", - "schema", - "doctor" - ], "sync": [ "list", "pull", @@ -30,12 +23,19 @@ "verify", "push", "deploy" + ], + "meta": [ + "caps", + "version", + "info", + "schema", + "doctor" ] }, "features": { - "ephemeralPrefix": true, - "prune": true, "remote": true, + "prune": true, + "ephemeralPrefix": true, "snapshot": false } } diff --git a/internal/driver/transport.go b/internal/driver/transport.go deleted file mode 100644 index f574bba..0000000 --- a/internal/driver/transport.go +++ /dev/null @@ -1,97 +0,0 @@ -package driver - -import ( - "context" - - "github.com/vista-cloud-dev/m-iris/clikit" -) - -// Transport is the verb-level seam every IRIS transport — local, docker, -// remote — implements. It is deliberately NOT a low-level run(argv) (risk B1): -// -// - local/docker exec pipes ObjectScript to `iris session -U NS` (stdin → -// stdout) and compiles via $SYSTEM.OBJ.Load; -// - remote exec is Atelier PUT + action/compile + a SQL action/query into a -// role-gated runner class — there is NO raw "run ObjectScript" endpoint, no -// stdout; results come back through a result global the transport reads. -// -// A single argv seam cannot model both shapes, so the contract is verb-level: -// each transport implements its own strategy and the rest of the driver is -// transport-agnostic. This is the interface m-iris contributes to the shared -// m-driver-sdk at the Phase-0 checkpoint (it must also fit m-ydb's session-pipe). -type Transport interface { - // Health is the readiness/liveness probe behind `lifecycle status --probe` - // and `wait`. remote: GET /api/atelier/v1/ → 200 + version. - Health(ctx context.Context) (Health, error) - - // Load stages routine source and compiles it (exec load). local/docker: - // $SYSTEM.OBJ.Load(path,"ck"); remote: Atelier PUT + action/compile. - Load(ctx context.Context, req LoadRequest) (LoadResult, error) - - // Exec runs an entryref (with args) or evaluates a single M command (exec - // run / eval). On a compile/runtime fault it returns ok via the result's - // EngineError, not a Go error — the fault is data (driver-contract §7). - Exec(ctx context.Context, req ExecRequest) (ExecResult, error) - - // ReadGlobal reads a global node (or subtree, per Depth) — data get/query - // and the result-global reads that back exec/cover orchestration. - ReadGlobal(ctx context.Context, req GlobalRef) (GlobalNode, error) - - // SetGlobal sets a single global node (data set), used to seed fixtures. - SetGlobal(ctx context.Context, ref, value string) error -} - -// Health is the probe result (driver-contract §3 health probes). -type Health struct { - Running bool `json:"running"` - Healthy bool `json:"healthy"` - Version string `json:"version,omitempty"` - LatencyMs int64 `json:"latencyMs"` -} - -// LoadRequest stages source for exec. Paths are files or a directory of -// routine source; Prefix (e.g. zzt) namespaces an ephemeral run so -// teardown is scoped to that prefix. -type LoadRequest struct { - Paths []string - Prefix string -} - -// LoadResult reports what was staged + compiled. -type LoadResult struct { - Loaded []string `json:"loaded"` -} - -// ExecRequest runs an entryref or evaluates a command. EntryRef and Command are -// mutually exclusive (run vs eval). -type ExecRequest struct { - EntryRef string // e.g. RUN^STDHARN (run) - Args []string // positional args → $ZCMDLINE / the entry's formallist - Command string // a single M command (eval) - Prefix string // ephemeral-run prefix -} - -// ExecResult is the unified outcome. Stdout is the captured device output -// (local/docker) or the runner's result-global text (remote). EngineError, when -// non-nil, is the §7 structured fault — the transport sets it instead of -// returning a Go error so the caller can render a RED-with-cause envelope. -type ExecResult struct { - Stdout string `json:"stdout"` - Status int `json:"status"` - EngineError *clikit.EngineError `json:"engineError,omitempty"` -} - -// GlobalRef addresses a global for a read. Order/Depth shape a subtree query -// (data query); empty means a single-node get. -type GlobalRef struct { - Ref string - Order string // "forward" | "reverse" - Depth int // 0 = this node only -} - -// GlobalNode is a global value, with children for a subtree read. -type GlobalNode struct { - Ref string `json:"ref"` - Value string `json:"value,omitempty"` - Nodes []GlobalNode `json:"nodes,omitempty"` -} diff --git a/internal/driver/transport_test.go b/internal/driver/transport_test.go deleted file mode 100644 index 4cf0a95..0000000 --- a/internal/driver/transport_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package driver - -import ( - "context" - "testing" - - "github.com/vista-cloud-dev/m-iris/clikit" -) - -// TestFakeTransport_RecordsAndScripts verifies the fake satisfies Transport, -// records calls in order, and returns scripted results — the substrate every -// unit test injects in place of a real IRIS. -func TestFakeTransport_RecordsAndScripts(t *testing.T) { - ctx := context.Background() - ft := &FakeTransport{ - ExecFn: func(_ context.Context, req ExecRequest) (ExecResult, error) { - if req.EntryRef == "BROKEN^X" { - return ExecResult{ - EngineError: &clikit.EngineError{ - Routine: "X", Line: 3, Mnemonic: "", Text: "no such routine", - }, - }, nil - } - return ExecResult{Stdout: "ok", Status: 0}, nil - }, - } - - // A clean run returns canned stdout. - res, err := ft.Exec(ctx, ExecRequest{EntryRef: "RUN^STDHARN"}) - if err != nil { - t.Fatalf("Exec: %v", err) - } - if res.Stdout != "ok" || res.Status != 0 { - t.Errorf("clean run = %+v, want stdout=ok status=0", res) - } - - // A fault is data, not a Go error: EngineError is populated, err is nil. - res, err = ft.Exec(ctx, ExecRequest{EntryRef: "BROKEN^X"}) - if err != nil { - t.Fatalf("fault should be data, not error: %v", err) - } - if res.EngineError == nil || res.EngineError.Mnemonic != "" { - t.Errorf("EngineError = %+v, want ", res.EngineError) - } - - // Unset verbs are safe zero-value no-ops and still record. - if _, err := ft.Health(ctx); err != nil { - t.Fatalf("Health: %v", err) - } - - wantVerbs := []string{"Exec", "Exec", "Health"} - if len(ft.Calls) != len(wantVerbs) { - t.Fatalf("recorded %d calls, want %d", len(ft.Calls), len(wantVerbs)) - } - for i, v := range wantVerbs { - if ft.Calls[i].Verb != v { - t.Errorf("call %d verb = %q, want %q", i, ft.Calls[i].Verb, v) - } - } -} diff --git a/internal/remote/integration_test.go b/internal/remote/integration_test.go index 2c3270e..2c96b30 100644 --- a/internal/remote/integration_test.go +++ b/internal/remote/integration_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" + mdriver "github.com/vista-cloud-dev/m-driver-sdk" "github.com/vista-cloud-dev/m-iris/internal/atelier" - "github.com/vista-cloud-dev/m-iris/internal/driver" ) // TestRemoteSpike_RealEngine is the REMOTE SPIKE (driver-plan §5 task 8): it @@ -55,7 +55,7 @@ func TestRemoteSpike_RealEngine(t *testing.T) { if err := tr.SetGlobal(ctx, `^mIrisIT("ping")`, "pong"); err != nil { t.Fatalf("SetGlobal: %v", err) } - node, err := tr.ReadGlobal(ctx, driver.GlobalRef{Ref: `^mIrisIT("ping")`}) + node, err := tr.ReadGlobal(ctx, mdriver.GlobalRef{Ref: `^mIrisIT("ping")`}) if err != nil { t.Fatalf("ReadGlobal: %v", err) } @@ -64,12 +64,12 @@ func TestRemoteSpike_RealEngine(t *testing.T) { } // 2. Eval a command; its side effect is visible through a result-global read. - if _, err := tr.Exec(ctx, driver.ExecRequest{ + if _, err := tr.Exec(ctx, mdriver.ExecRequest{ Command: `set ^mIrisRun("zzit","out")="evaled"`, Prefix: "zzit", }); err != nil { t.Fatalf("Exec eval: %v", err) } - out, err := tr.ReadGlobal(ctx, driver.GlobalRef{Ref: `^mIrisRun("zzit","out")`}) + out, err := tr.ReadGlobal(ctx, mdriver.GlobalRef{Ref: `^mIrisRun("zzit","out")`}) if err != nil { t.Fatalf("ReadGlobal out: %v", err) } @@ -78,7 +78,7 @@ func TestRemoteSpike_RealEngine(t *testing.T) { } // 3. A deliberate fault surfaces as a structured EngineError, not a Go error. - res, err := tr.Exec(ctx, driver.ExecRequest{ + res, err := tr.Exec(ctx, mdriver.ExecRequest{ Command: `set x=^mIrisNoSuchGlobal(1)`, Prefix: "zzfault", }) if err != nil { diff --git a/internal/remote/remote.go b/internal/remote/remote.go index 3766036..5bc4968 100644 --- a/internal/remote/remote.go +++ b/internal/remote/remote.go @@ -16,9 +16,8 @@ import ( "strconv" "strings" - "github.com/vista-cloud-dev/m-iris/clikit" + mdriver "github.com/vista-cloud-dev/m-driver-sdk" "github.com/vista-cloud-dev/m-iris/internal/atelier" - "github.com/vista-cloud-dev/m-iris/internal/driver" ) //go:embed runner/m_iris.Runner.cls @@ -38,13 +37,13 @@ type AtelierAPI interface { } // Transport is the remote (Atelier REST + SQL runner) strategy. It satisfies -// driver.Transport so the rest of m-iris is transport-agnostic. +// mdriver.Transport so the rest of m-iris is transport-agnostic. type Transport struct { api AtelierAPI deployed bool // runner PUT+compiled this process } -var _ driver.Transport = (*Transport)(nil) +var _ mdriver.Transport = (*Transport)(nil) // New builds a remote transport over an Atelier client. func New(api AtelierAPI) *Transport { return &Transport{api: api} } @@ -83,9 +82,9 @@ func runID(prefix string) string { // Exec runs an entryref or evaluates a command through the runner. A compile/ // runtime fault is data, not a Go error: the runner records it in the result // global and Exec returns it as ExecResult.EngineError (contract §7). -func (t *Transport) Exec(ctx context.Context, req driver.ExecRequest) (driver.ExecResult, error) { +func (t *Transport) Exec(ctx context.Context, req mdriver.ExecRequest) (mdriver.ExecResult, error) { if err := t.ensureRunner(ctx); err != nil { - return driver.ExecResult{}, err + return mdriver.ExecResult{}, err } rid := runID(req.Prefix) @@ -98,41 +97,41 @@ func (t *Transport) Exec(ctx context.Context, req driver.ExecRequest) (driver.Ex rows, err = t.api.Query(ctx, "SELECT m_iris.RunRef(?,?,?) AS status", rid, req.EntryRef, strings.Join(req.Args, "\x01")) default: - return driver.ExecResult{}, fmt.Errorf("remote: exec needs an entryref or a command") + return mdriver.ExecResult{}, fmt.Errorf("remote: exec needs an entryref or a command") } if err != nil { - return driver.ExecResult{}, err + return mdriver.ExecResult{}, err } status := firstCol(rows, "status") switch status { case "7": - return driver.ExecResult{}, fmt.Errorf("remote: runner refused — caller lacks the m_iris_runner role / action-query privilege") + return mdriver.ExecResult{}, fmt.Errorf("remote: runner refused — caller lacks the m_iris_runner role / action-query privilege") case "5": eng, rerr := t.readEngineError(ctx, rid) if rerr != nil { - return driver.ExecResult{}, rerr + return mdriver.ExecResult{}, rerr } - return driver.ExecResult{Status: 5, EngineError: eng}, nil + return mdriver.ExecResult{Status: 5, EngineError: eng}, nil } out, err := t.getGlobal(ctx, fmt.Sprintf(`^mIrisRun(%q,"out")`, rid)) if err != nil { - return driver.ExecResult{}, err + return mdriver.ExecResult{}, err } st, _ := strconv.Atoi(status) - return driver.ExecResult{Stdout: out, Status: st}, nil + return mdriver.ExecResult{Stdout: out, Status: st}, nil } // readEngineError reads ^mIrisRun(rid,"error") and parses the §7 frame // "mnemonic|routine|line|text". -func (t *Transport) readEngineError(ctx context.Context, rid string) (*clikit.EngineError, error) { +func (t *Transport) readEngineError(ctx context.Context, rid string) (*mdriver.EngineError, error) { raw, err := t.getGlobal(ctx, fmt.Sprintf(`^mIrisRun(%q,"error")`, rid)) if err != nil { return nil, err } parts := strings.SplitN(raw, "|", 4) - eng := &clikit.EngineError{} + eng := &mdriver.EngineError{} if len(parts) > 0 { eng.Mnemonic = parts[0] } @@ -149,15 +148,15 @@ func (t *Transport) readEngineError(ctx context.Context, rid string) (*clikit.En } // ReadGlobal reads a single global node via the runner (contract data.get). -func (t *Transport) ReadGlobal(ctx context.Context, req driver.GlobalRef) (driver.GlobalNode, error) { +func (t *Transport) ReadGlobal(ctx context.Context, req mdriver.GlobalRef) (mdriver.GlobalNode, error) { if err := t.ensureRunner(ctx); err != nil { - return driver.GlobalNode{}, err + return mdriver.GlobalNode{}, err } v, err := t.getGlobal(ctx, req.Ref) if err != nil { - return driver.GlobalNode{}, err + return mdriver.GlobalNode{}, err } - return driver.GlobalNode{Ref: req.Ref, Value: v}, nil + return mdriver.GlobalNode{Ref: req.Ref, Value: v}, nil } // SetGlobal sets a single global node via the runner (contract data.set). @@ -182,41 +181,41 @@ func (t *Transport) getGlobal(ctx context.Context, ref string) (string, error) { // Load PUT+compiles routine source over Atelier (contract exec.load on remote). // Compile diagnostics are surfaced as an EngineError rather than a Go error — // a failed compile is a bad result, not a transport failure. -func (t *Transport) Load(ctx context.Context, req driver.LoadRequest) (driver.LoadResult, error) { +func (t *Transport) Load(ctx context.Context, req mdriver.LoadRequest) (mdriver.LoadResult, error) { files, err := expandPaths(req.Paths) if err != nil { - return driver.LoadResult{}, err + return mdriver.LoadResult{}, err } var loaded []string for _, f := range files { content, rerr := os.ReadFile(f) if rerr != nil { - return driver.LoadResult{}, rerr + return mdriver.LoadResult{}, rerr } name := req.Prefix + filepath.Base(f) if _, perr := t.api.PutDoc(ctx, name, splitLines(string(content))); perr != nil { - return driver.LoadResult{}, perr + return mdriver.LoadResult{}, perr } loaded = append(loaded, name) } if len(loaded) > 0 { if _, cerr := t.api.Compile(ctx, loaded, "cuk"); cerr != nil { - return driver.LoadResult{}, cerr + return mdriver.LoadResult{}, cerr } } - return driver.LoadResult{Loaded: loaded}, nil + return mdriver.LoadResult{Loaded: loaded}, nil } // Health proves the remote substrate is reachable AND that the caller actually // holds the action/query privilege (a SELECT 1, not just TCP reachability — // risks C3, C7). Version enrichment lands with the M1 root-endpoint probe. -func (t *Transport) Health(ctx context.Context) (driver.Health, error) { +func (t *Transport) Health(ctx context.Context) (mdriver.Health, error) { rows, err := t.api.Query(ctx, "SELECT 1 AS one") if err != nil { - return driver.Health{Running: false, Healthy: false}, err + return mdriver.Health{Running: false, Healthy: false}, err } healthy := firstCol(rows, "one") == "1" - return driver.Health{Running: true, Healthy: healthy}, nil + return mdriver.Health{Running: true, Healthy: healthy}, nil } func firstCol(rows []map[string]string, col string) string { diff --git a/internal/remote/remote_test.go b/internal/remote/remote_test.go index 657bc31..2359096 100644 --- a/internal/remote/remote_test.go +++ b/internal/remote/remote_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" + mdriver "github.com/vista-cloud-dev/m-driver-sdk" "github.com/vista-cloud-dev/m-iris/internal/atelier" - "github.com/vista-cloud-dev/m-iris/internal/driver" ) // fakeAPI scripts the runner's SQL surface in-memory: it records PUT/Compile @@ -66,7 +66,7 @@ func TestRemoteExec_DeploysRunnerOnceAndRunsClean(t *testing.T) { tr := New(api) ctx := context.Background() - res, err := tr.Exec(ctx, driver.ExecRequest{EntryRef: "RUN^STDHARN", Prefix: "zzt42"}) + res, err := tr.Exec(ctx, mdriver.ExecRequest{EntryRef: "RUN^STDHARN", Prefix: "zzt42"}) if err != nil { t.Fatalf("Exec: %v", err) } @@ -81,7 +81,7 @@ func TestRemoteExec_DeploysRunnerOnceAndRunsClean(t *testing.T) { t.Errorf("compiles = %v, want one", api.compiles) } // ...and not re-deployed on a second call. - if _, err := tr.Exec(ctx, driver.ExecRequest{EntryRef: "OTHER^RTN", Prefix: "zzt42"}); err != nil { + if _, err := tr.Exec(ctx, mdriver.ExecRequest{EntryRef: "OTHER^RTN", Prefix: "zzt42"}); err != nil { t.Fatalf("second Exec: %v", err) } if len(api.puts) != 1 { @@ -97,7 +97,7 @@ func TestRemoteExec_FaultBecomesEngineError(t *testing.T) { api.runFault = &clikit3Engine{mnemonic: "", routine: "XLFISO", line: "12", text: "global undefined"} tr := New(api) - res, err := tr.Exec(context.Background(), driver.ExecRequest{EntryRef: "BROKEN^XLFISO", Prefix: "zzt7"}) + res, err := tr.Exec(context.Background(), mdriver.ExecRequest{EntryRef: "BROKEN^XLFISO", Prefix: "zzt7"}) if err != nil { t.Fatalf("a fault must be data, not a Go error: %v", err) } @@ -119,7 +119,7 @@ func TestRemoteData_SetGetRoundTrip(t *testing.T) { if err := tr.SetGlobal(ctx, ref, "hello"); err != nil { t.Fatalf("SetGlobal: %v", err) } - node, err := tr.ReadGlobal(ctx, driver.GlobalRef{Ref: ref}) + node, err := tr.ReadGlobal(ctx, mdriver.GlobalRef{Ref: ref}) if err != nil { t.Fatalf("ReadGlobal: %v", err) } diff --git a/meta.go b/meta.go index e4117d8..33f3433 100644 --- a/meta.go +++ b/meta.go @@ -3,6 +3,7 @@ package main import ( "fmt" + mdriver "github.com/vista-cloud-dev/m-driver-sdk" "github.com/vista-cloud-dev/m-iris/clikit" "github.com/vista-cloud-dev/m-iris/internal/config" "github.com/vista-cloud-dev/m-iris/internal/driver" @@ -33,11 +34,9 @@ func (capsCmd) Run(cc *clikit.Context) error { [2]string{"engine", caps.Engine}, [2]string{"transports", fmt.Sprint(caps.Transports)}, ) - for _, axis := range []string{"lifecycle", "sync", "exec", "data", "cover", "admin", "meta"} { - if verbs, ok := caps.Axes[axis]; ok { - cc.Rule(axis) - fmt.Fprintln(cc.Stdout, " "+fmt.Sprint(verbs)) - } + for _, axis := range caps.Axes.Wired() { + cc.Rule(axis.Name) + fmt.Fprintln(cc.Stdout, " "+fmt.Sprint(axis.Verbs)) } }) } @@ -63,7 +62,7 @@ func (infoCmd) Run(cc *clikit.Context, conn *config.Conn) error { res := infoResult{ Driver: "m-iris", Engine: "iris", - Contract: driver.ContractVersion, + Contract: mdriver.ContractVersion, Build: clikit.Version, BaseURL: conn.BaseURL, Namespace: conn.Namespace, diff --git a/meta_test.go b/meta_test.go index fdee293..e233a9f 100644 --- a/meta_test.go +++ b/meta_test.go @@ -4,8 +4,8 @@ import ( "encoding/json" "testing" + mdriver "github.com/vista-cloud-dev/m-driver-sdk" "github.com/vista-cloud-dev/m-iris/internal/config" - "github.com/vista-cloud-dev/m-iris/internal/driver" ) // TestCapsCommand_EmitsHonestDocument runs `meta caps` and asserts the envelope @@ -17,8 +17,8 @@ func TestCapsCommand_EmitsHonestDocument(t *testing.T) { t.Fatalf("caps: %v", err) } var env struct { - OK bool `json:"ok"` - Data driver.Caps `json:"data"` + OK bool `json:"ok"` + Data mdriver.Caps `json:"data"` } if err := json.Unmarshal(buf.Bytes(), &env); err != nil { t.Fatalf("decode caps envelope: %v\n%s", err, buf.String()) @@ -26,16 +26,16 @@ func TestCapsCommand_EmitsHonestDocument(t *testing.T) { if !env.OK { t.Error("caps envelope ok=false") } - if env.Data.Engine != "iris" || env.Data.Contract != driver.ContractVersion { - t.Errorf("caps data = %+v, want engine=iris contract=%s", env.Data, driver.ContractVersion) + if env.Data.Engine != "iris" || env.Data.Contract != mdriver.ContractVersion { + t.Errorf("caps data = %+v, want engine=iris contract=%s", env.Data, mdriver.ContractVersion) } - if !env.Data.Features["remote"] { + if !env.Data.Features.Remote { t.Error("caps must advertise the remote transport for IRIS") } // Honesty: every axis caps advertises must list at least one verb. - for axis, verbs := range env.Data.Axes { - if len(verbs) == 0 { - t.Errorf("axis %q advertised with no verbs", axis) + for _, axis := range env.Data.Axes.Wired() { + if len(axis.Verbs) == 0 { + t.Errorf("axis %q advertised with no verbs", axis.Name) } } } @@ -54,8 +54,8 @@ func TestInfoCommand_ReportsIdentity(t *testing.T) { if err := json.Unmarshal(buf.Bytes(), &env); err != nil { t.Fatalf("decode info envelope: %v\n%s", err, buf.String()) } - if env.Data.Engine != "iris" || env.Data.Contract != driver.ContractVersion { - t.Errorf("info = %+v, want engine=iris contract=%s", env.Data, driver.ContractVersion) + if env.Data.Engine != "iris" || env.Data.Contract != mdriver.ContractVersion { + t.Errorf("info = %+v, want engine=iris contract=%s", env.Data, mdriver.ContractVersion) } if env.Data.Namespace != "VISTA" { t.Errorf("info namespace = %q, want VISTA", env.Data.Namespace) From 6fada56bc19b9216380a907e444b1209d5c41071 Mon Sep 17 00:00:00 2001 From: Rafael Date: Thu, 4 Jun 2026 06:53:00 -0400 Subject: [PATCH 04/24] m-iris: pin published m-driver-sdk (drop local replace) Switch from the local replace ../m-driver-sdk to the published module github.com/vista-cloud-dev/m-driver-sdk (pseudo-version, proxy+sumdb verified) now that the SDK is on GitHub, so CI resolves it standalone. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 4 +--- go.sum | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e852adc..79f4c15 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.3 require ( github.com/alecthomas/kong v1.15.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/vista-cloud-dev/m-driver-sdk v0.0.0-20260604101652-4f3e82636f2e github.com/willabides/kongplete v0.4.0 golang.org/x/term v0.43.0 ) @@ -24,9 +25,6 @@ require ( github.com/posener/complete v1.2.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect - github.com/vista-cloud-dev/m-driver-sdk v0.0.0 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.44.0 // indirect ) - -replace github.com/vista-cloud-dev/m-driver-sdk => ../m-driver-sdk diff --git a/go.sum b/go.sum index 84ce838..a445393 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/vista-cloud-dev/m-driver-sdk v0.0.0-20260604101652-4f3e82636f2e h1:l/tIoLWa9sJzLADG6EDoiwBYT1vuwgHa/M6kclx1uYQ= +github.com/vista-cloud-dev/m-driver-sdk v0.0.0-20260604101652-4f3e82636f2e/go.mod h1:0Qkz38Qhgyr5nYQeqgthkMHt4zVJMN3j79Kfr+THtpw= github.com/willabides/kongplete v0.4.0 h1:eivXxkp5ud5+4+NVN9e4goxC5mSh3n1RHov+gsblM2g= github.com/willabides/kongplete v0.4.0/go.mod h1:0P0jtWD9aTsqPSUAl4de35DLghrr57XcayPyvqSi2X8= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= From 5668831c7e1d90976d90e955336665444e71bd53 Mon Sep 17 00:00:00 2001 From: Rafael Date: Thu, 4 Jun 2026 07:03:29 -0400 Subject: [PATCH 05/24] deps: pin m-driver-sdk v0.1.0 (tagged release) Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 79f4c15..b3cfc96 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.3 require ( github.com/alecthomas/kong v1.15.0 github.com/charmbracelet/lipgloss v1.1.0 - github.com/vista-cloud-dev/m-driver-sdk v0.0.0-20260604101652-4f3e82636f2e + github.com/vista-cloud-dev/m-driver-sdk v0.1.0 github.com/willabides/kongplete v0.4.0 golang.org/x/term v0.43.0 ) diff --git a/go.sum b/go.sum index a445393..5420092 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/vista-cloud-dev/m-driver-sdk v0.0.0-20260604101652-4f3e82636f2e h1:l/tIoLWa9sJzLADG6EDoiwBYT1vuwgHa/M6kclx1uYQ= -github.com/vista-cloud-dev/m-driver-sdk v0.0.0-20260604101652-4f3e82636f2e/go.mod h1:0Qkz38Qhgyr5nYQeqgthkMHt4zVJMN3j79Kfr+THtpw= +github.com/vista-cloud-dev/m-driver-sdk v0.1.0 h1:2iCneXr4opmyuGhySJ1CSyCuoHO48NtC5mHkGsae1Kk= +github.com/vista-cloud-dev/m-driver-sdk v0.1.0/go.mod h1:0Qkz38Qhgyr5nYQeqgthkMHt4zVJMN3j79Kfr+THtpw= github.com/willabides/kongplete v0.4.0 h1:eivXxkp5ud5+4+NVN9e4goxC5mSh3n1RHov+gsblM2g= github.com/willabides/kongplete v0.4.0/go.mod h1:0P0jtWD9aTsqPSUAl4de35DLghrr57XcayPyvqSi2X8= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= From 21ceddaa0ffc676126a72a56f079a1b8b472e417 Mon Sep 17 00:00:00 2001 From: Rafael Date: Thu, 4 Jun 2026 07:08:23 -0400 Subject: [PATCH 06/24] refactor: adopt SDK-owned M1 payload shapes; pin m-driver-sdk v0.2.0 doctor/lifecycle payloads now alias mdriver.{Check,DoctorResult,Status, StateResult} so m-ydb and m-iris emit identical JSON. Aliases keep existing literals/renderers/goldens unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- doctor.go | 19 +++++++------------ go.mod | 2 +- go.sum | 4 ++-- lifecycle.go | 22 +++++++--------------- 4 files changed, 17 insertions(+), 30 deletions(-) diff --git a/doctor.go b/doctor.go index 7ba2688..c603b46 100644 --- a/doctor.go +++ b/doctor.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + mdriver "github.com/vista-cloud-dev/m-driver-sdk" "github.com/vista-cloud-dev/m-iris/clikit" "github.com/vista-cloud-dev/m-iris/internal/atelier" "github.com/vista-cloud-dev/m-iris/internal/config" @@ -21,18 +22,12 @@ const minIRISYear = 2022 // 0 all green, 6 engine-unreachable, 5 a check failed. type doctorCmd struct{} -type doctorCheck struct { - Name string `json:"name"` - OK bool `json:"ok"` - Detail string `json:"detail,omitempty"` - Fix string `json:"fix,omitempty"` -} - -type doctorResult struct { - Transport string `json:"transport"` - OK bool `json:"ok"` - Checks []doctorCheck `json:"checks"` -} +// The doctor payload shapes are SDK-owned so m-ydb and m-iris emit identical +// JSON m-cli reads (aliases keep the existing literals/renderers unchanged). +type ( + doctorCheck = mdriver.Check + doctorResult = mdriver.DoctorResult +) func (doctorCmd) Run(cc *clikit.Context, conn *config.Conn) error { if err := remoteOnly(conn); err != nil { diff --git a/go.mod b/go.mod index b3cfc96..b7303d2 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.3 require ( github.com/alecthomas/kong v1.15.0 github.com/charmbracelet/lipgloss v1.1.0 - github.com/vista-cloud-dev/m-driver-sdk v0.1.0 + github.com/vista-cloud-dev/m-driver-sdk v0.2.0 github.com/willabides/kongplete v0.4.0 golang.org/x/term v0.43.0 ) diff --git a/go.sum b/go.sum index 5420092..144179d 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/vista-cloud-dev/m-driver-sdk v0.1.0 h1:2iCneXr4opmyuGhySJ1CSyCuoHO48NtC5mHkGsae1Kk= -github.com/vista-cloud-dev/m-driver-sdk v0.1.0/go.mod h1:0Qkz38Qhgyr5nYQeqgthkMHt4zVJMN3j79Kfr+THtpw= +github.com/vista-cloud-dev/m-driver-sdk v0.2.0 h1:YcwmP0os9kG/TIpGAvEktnGiWs7oR0whToKV4su49P0= +github.com/vista-cloud-dev/m-driver-sdk v0.2.0/go.mod h1:0Qkz38Qhgyr5nYQeqgthkMHt4zVJMN3j79Kfr+THtpw= github.com/willabides/kongplete v0.4.0 h1:eivXxkp5ud5+4+NVN9e4goxC5mSh3n1RHov+gsblM2g= github.com/willabides/kongplete v0.4.0/go.mod h1:0P0jtWD9aTsqPSUAl4de35DLghrr57XcayPyvqSi2X8= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= diff --git a/lifecycle.go b/lifecycle.go index b3aa5be..5754d05 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + mdriver "github.com/vista-cloud-dev/m-driver-sdk" "github.com/vista-cloud-dev/m-iris/clikit" "github.com/vista-cloud-dev/m-iris/internal/atelier" "github.com/vista-cloud-dev/m-iris/internal/config" @@ -27,16 +28,12 @@ type lifecycleCmd struct { Destroy lifeDestroyCmd `cmd:"" help:"Remove an instance/namespace (remote: unsupported over Atelier, exit 7)."` } -// lifecycleStatus is the status/probe payload (driver-contract §5.1). -type lifecycleStatus struct { - Transport string `json:"transport"` - Running bool `json:"running"` - Healthy bool `json:"healthy"` - Version string `json:"version,omitempty"` - Namespaces []string `json:"namespaces,omitempty"` - LatencyMs int64 `json:"latencyMs"` - Endpoint string `json:"endpoint,omitempty"` -} +// The lifecycle status/state payloads are SDK-owned so m-ydb and m-iris emit +// identical JSON m-cli reads (aliases keep the existing literals/renderers). +type ( + lifecycleStatus = mdriver.Status + lifeStateResult = mdriver.StateResult +) // remoteOnly returns a not-yet-implemented error for local/docker (only remote // is wired today) and nil for remote. An empty transport defaults to remote. @@ -136,11 +133,6 @@ func (c lifeStatusCmd) Run(cc *clikit.Context, conn *config.Conn) error { type lifeUpCmd struct{} -type lifeStateResult struct { - State string `json:"state"` - Endpoint string `json:"endpoint,omitempty"` -} - func (lifeUpCmd) Run(cc *clikit.Context, conn *config.Conn) error { if err := remoteOnly(conn); err != nil { return err From a502071f7431eeb2af793cf8fd3eec34e0e1f40f Mon Sep 17 00:00:00 2001 From: Rafael Date: Thu, 4 Jun 2026 07:54:24 -0400 Subject: [PATCH 07/24] m-iris: fix real-IRIS compatibility (validated against IRIS 2026.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real-engine validation of m-iris (M0 remote spike + M1 status/doctor) against a disposable IRIS Community 2026.1 container. Four latent bugs that the fake-Atelier unit tier never caught, each now fixed + re-tested green: 1. Runner class name `m_iris.Runner` is INVALID — IRIS class/package names forbid underscores (#16006). Renamed to `m.iris.Runner`; package "m.iris" projects to SQL schema "m_iris", so all m_iris.* SQL call sites are unchanged. 2. Atelier error `code` is a JSON number on IRIS 2026.1 (client typed it string) → compile-response decode failed. Added errCode (accepts string or number). 3. Runner ObjectScript used spaces after commas → #1043 "QUIT argument not allowed" in `quit ..fault(rid, ex)`. Removed comma-whitespace; catch blocks now `do ..fault(...)` + fall through to the single return. 4. ServerInfo hit the version-prefixed root `/api/atelier/v1/` (404 on modern IRIS) → status/doctor unreachable. Switched to the unversioned `/api/atelier/` version-discovery root (present in every Atelier release). Validated GREEN vs IRIS 2026.1: remote spike (deploy runner, set/get/eval, structured EngineError), lifecycle status (running/healthy/version/namespaces), meta doctor (all checks). Added `make test-it` (gated real-engine tier). Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 12 ++++++ internal/atelier/serverinfo.go | 19 +++++++--- internal/atelier/serverinfo_test.go | 12 +++--- internal/atelier/types.go | 26 +++++++++++-- internal/remote/remote.go | 10 +++-- .../{m_iris.Runner.cls => m.iris.Runner.cls} | 37 +++++++++++-------- 6 files changed, 84 insertions(+), 32 deletions(-) rename internal/remote/runner/{m_iris.Runner.cls => m.iris.Runner.cls} (76%) diff --git a/Makefile b/Makefile index 5f236c6..0ceddb8 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,18 @@ lint: test: CGO_ENABLED=1 go test $(GOFLAGS) -race -cover ./... +# Real-engine integration tier (gated). Needs a disposable IRIS container with +# Atelier reachable. Defaults target the local m-test-iris (CE on port 52774); +# override IRIS_* to point elsewhere. NEVER point at the shared vista-iris. +IRIS_BASE_URL ?= http://localhost:52774/api/atelier/v1/ +IRIS_NAMESPACE ?= USER +IRIS_USER ?= _SYSTEM +IRIS_PASSWORD ?= testsys +test-it: + M_IRIS_IT=1 M_IRIS_BASE_URL=$(IRIS_BASE_URL) M_IRIS_NAMESPACE=$(IRIS_NAMESPACE) \ + M_IRIS_USER=$(IRIS_USER) M_IRIS_PASSWORD=$(IRIS_PASSWORD) \ + go test $(GOFLAGS) -count=1 -run RealEngine ./internal/remote/ -v + tidy: go mod tidy diff --git a/internal/atelier/serverinfo.go b/internal/atelier/serverinfo.go index 4c978f5..dcb9524 100644 --- a/internal/atelier/serverinfo.go +++ b/internal/atelier/serverinfo.go @@ -4,9 +4,10 @@ import ( "context" "encoding/json" "fmt" + "strings" ) -// ServerInfo is the Atelier root probe result (GET /api/atelier/v1/): the engine +// ServerInfo is the Atelier root probe result (GET /api/atelier/): the engine // version, the Atelier API level, and the namespaces the credential can see. It // is the substrate for lifecycle status / health probes / doctor / meta info on // the remote transport. @@ -16,11 +17,19 @@ type ServerInfo struct { Namespaces []string `json:"namespaces,omitempty"` } -// ServerInfo issues GET against the Atelier base root and decodes the server -// descriptor. A 401/403 comes back as a typed *HTTPError (see IsUnauthorized / -// IsForbidden) so doctor can report auth state precisely. +// ServerInfo issues GET against the UNVERSIONED Atelier root and decodes the +// server descriptor. The version-prefixed root (…/api/atelier/v1/) 404s on +// modern IRIS (e.g. 2026.1); the descriptor lives at …/api/atelier/, the +// version-discovery endpoint present in every Atelier release. A 401/403 comes +// back as a typed *HTTPError (see IsUnauthorized / IsForbidden) so doctor can +// report auth state precisely. func (c *Client) ServerInfo(ctx context.Context) (*ServerInfo, error) { - u := *c.base // the base already ends in /api/atelier/v1/ + u := *c.base // base ends in /api/atelier/v1/ — strip the version segment + p := strings.TrimRight(u.Path, "/") + if i := strings.LastIndex(p, "/"); i >= 0 { + p = p[:i+1] + } + u.Path = p var env Envelope if err := c.get(ctx, &u, &env); err != nil { diff --git a/internal/atelier/serverinfo_test.go b/internal/atelier/serverinfo_test.go index 8b97a63..05df45e 100644 --- a/internal/atelier/serverinfo_test.go +++ b/internal/atelier/serverinfo_test.go @@ -7,9 +7,11 @@ import ( "testing" ) -// TestServerInfo_RoundTrip drives the Atelier root probe (GET /api/atelier/v1/), -// the foundation of lifecycle status / health / doctor: it returns the engine -// version + the namespaces the credential can see. +// TestServerInfo_RoundTrip drives the Atelier root probe, the foundation of +// lifecycle status / health / doctor: it returns the engine version + the +// namespaces the credential can see. The probe targets the UNVERSIONED root +// (/api/atelier/) — the version-prefixed root 404s on modern IRIS (validated +// against IRIS 2026.1). func TestServerInfo_RoundTrip(t *testing.T) { var gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -26,8 +28,8 @@ func TestServerInfo_RoundTrip(t *testing.T) { if err != nil { t.Fatalf("ServerInfo: %v", err) } - if gotPath != "/api/atelier/v1/" { - t.Errorf("path = %q, want /api/atelier/v1/", gotPath) + if gotPath != "/api/atelier/" { + t.Errorf("path = %q, want /api/atelier/ (unversioned root)", gotPath) } if info.Version != "IRIS for UNIX (Ubuntu Server LTS) 2024.1" || info.API != 7 { t.Errorf("info = %+v", info) diff --git a/internal/atelier/types.go b/internal/atelier/types.go index ebf755b..3bdcea2 100644 --- a/internal/atelier/types.go +++ b/internal/atelier/types.go @@ -23,12 +23,32 @@ type Status struct { Summary string `json:"summary,omitempty"` } +// errCode is an Atelier error code, rendered as a JSON string by older servers +// and as a JSON number by IRIS 2026.1+. It is normalized to a string either way. +type errCode string + +func (c *errCode) UnmarshalJSON(b []byte) error { + if len(b) == 0 || string(b) == "null" { + return nil + } + if b[0] == '"' { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + *c = errCode(s) + return nil + } + *c = errCode(b) // numeric code → its literal text (e.g. 16006) + return nil +} + // APIError decodes both forms Atelier servers have used across versions: the // object form ({"error":"…","code":"…"}) and the bare-string form. type APIError struct { - Error string `json:"error"` - Code string `json:"code,omitempty"` - ID string `json:"id,omitempty"` + Error string `json:"error"` + Code errCode `json:"code,omitempty"` + ID string `json:"id,omitempty"` } // UnmarshalJSON accepts either a JSON string or a JSON object. diff --git a/internal/remote/remote.go b/internal/remote/remote.go index 5bc4968..980bff3 100644 --- a/internal/remote/remote.go +++ b/internal/remote/remote.go @@ -1,7 +1,7 @@ // Package remote is the IRIS `remote` transport: vendor logic that drives an // IRIS namespace entirely over the Atelier REST API. Because Atelier has no raw // "run ObjectScript" endpoint, every ObjectScript operation rides the -// m_iris.Runner class (runner/m_iris.Runner.cls): the transport PUT+compiles it +// m.iris.Runner class (runner/m.iris.Runner.cls): the transport PUT+compiles it // once, then invokes its SQL-projected procedures via action/query and reads // results back out of a result global. This is the entire remote substrate // (driver-plan §5 task 8, risk B2); remote exec/data/cover/admin all sit on it. @@ -20,11 +20,13 @@ import ( "github.com/vista-cloud-dev/m-iris/internal/atelier" ) -//go:embed runner/m_iris.Runner.cls +//go:embed runner/m.iris.Runner.cls var runnerSource string -// runnerDoc is the Atelier docname of the runner class. -const runnerDoc = "m_iris.Runner.cls" +// runnerDoc is the Atelier docname of the runner class. Package "m.iris" (dots, +// no underscore — IRIS class names forbid underscores) projects its SqlProcs +// into the SQL schema "m_iris", so the m_iris.* SQL calls below are unchanged. +const runnerDoc = "m.iris.Runner.cls" // AtelierAPI is the slice of the Atelier client the remote transport needs. It // is narrowed to an interface so unit tests inject a fake (recording PUT/Compile diff --git a/internal/remote/runner/m_iris.Runner.cls b/internal/remote/runner/m.iris.Runner.cls similarity index 76% rename from internal/remote/runner/m_iris.Runner.cls rename to internal/remote/runner/m.iris.Runner.cls index d839a61..6b5778a 100644 --- a/internal/remote/runner/m_iris.Runner.cls +++ b/internal/remote/runner/m.iris.Runner.cls @@ -1,4 +1,7 @@ -/// m_iris.Runner is the remote execution substrate for the m-iris driver. +/// m.iris.Runner is the remote execution substrate for the m-iris driver. +/// (Package "m.iris" — NOT "m_iris": IRIS class names forbid underscores; the +/// package's dots map to the SQL schema "m_iris", so the SqlProc names below +/// stay m_iris.*.) /// /// Atelier exposes no raw "run ObjectScript" endpoint, and Go has no official /// IRIS Native SDK — so on the `remote` transport ALL ObjectScript (exec, data, @@ -8,10 +11,14 @@ /// (driver-plan §5 task 8, risk B2). It is a security boundary, so every entry /// point is role-gated AND parameterized (callers bind values, never concatenate). /// +/// NB: executable lines avoid spaces after commas — ObjectScript treats a space +/// inside a command argument as a terminator (a space after a comma in a QUIT +/// expression triggers #1043 "QUIT argument not allowed"). +/// /// Result global, keyed by a caller-supplied run id (rid): /// ^mIrisRun(rid,"status") = 0 ok | 5 fault | 7 unauthorized /// ^mIrisRun(rid,"error") = "mnemonic|routine|line|text" (on fault, contract §7) -Class m_iris.Runner Extends %RegisteredObject +Class m.iris.Runner Extends %RegisteredObject { /// authorized reports whether the caller may drive the substrate. Holding the @@ -20,18 +27,18 @@ Class m_iris.Runner Extends %RegisteredObject /// granted only to m_iris_runner (defense in depth: app-role AND SQL privilege). ClassMethod authorized() As %Boolean [ Private ] { - quit ($SYSTEM.Security.Check("m_iris_runner", "USE") '= "") || ($USERNAME = "_SYSTEM") + quit ($SYSTEM.Security.Check("m_iris_runner","USE")'="")||($USERNAME="_SYSTEM") } /// fault records a caught exception into the result global in contract-§7 shape /// (mnemonic|routine|line|text) and returns status 5. ClassMethod fault(rid As %String, ex As %Exception.AbstractException) As %Integer [ Private ] { - set loc = ex.Location - set routine = $piece(loc, "^", 2) - set line = $piece($piece(loc, "^", 1), "+", 2) - set ^mIrisRun(rid, "status") = 5 - set ^mIrisRun(rid, "error") = ex.Name _ "|" _ routine _ "|" _ line _ "|" _ ex.DisplayString() + set loc=ex.Location + set routine=$piece(loc,"^",2) + set line=$piece($piece(loc,"^",1),"+",2) + set ^mIrisRun(rid,"status")=5 + set ^mIrisRun(rid,"error")=ex.Name_"|"_routine_"|"_line_"|"_ex.DisplayString() quit 5 } @@ -42,13 +49,13 @@ ClassMethod RunRef(rid As %String, ref As %String, args As %String = "") As %Int { if '..authorized() quit 7 kill ^mIrisRun(rid) - set ^mIrisRun(rid, "status") = 0 + set ^mIrisRun(rid,"status")=0 try { do @ref } catch ex { - quit ..fault(rid, ex) + do ..fault(rid,ex) } - quit ^mIrisRun(rid, "status") + quit ^mIrisRun(rid,"status") } /// Eval executes one ObjectScript command line via XECUTE (contract exec.eval). @@ -57,13 +64,13 @@ ClassMethod Eval(rid As %String, cmd As %String) As %Integer [ SqlName = "Eval", { if '..authorized() quit 7 kill ^mIrisRun(rid) - set ^mIrisRun(rid, "status") = 0 + set ^mIrisRun(rid,"status")=0 try { xecute cmd } catch ex { - quit ..fault(rid, ex) + do ..fault(rid,ex) } - quit ^mIrisRun(rid, "status") + quit ^mIrisRun(rid,"status") } /// GetGlobal returns $get(@ref); ref is a full global reference, e.g. @@ -80,7 +87,7 @@ ClassMethod GetGlobal(ref As %String) As %String [ SqlName = "GetGlobal", SqlPro ClassMethod SetGlobal(ref As %String, value As %String = "") As %Integer [ SqlName = "SetGlobal", SqlProc ] { if '..authorized() quit 7 - set @ref = value + set @ref=value quit 1 } From b0531edac76764a7ca9e88e5a41640fc44aac02e Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Fri, 5 Jun 2026 23:42:45 -0400 Subject: [PATCH 08/24] m-iris M2 (sync axis complete): diff + rm + push --from + bare-name --filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the sync axis to 8-verb parity with m-ydb (plan §5 task 6): - sync diff [--from DIR]: unified diff of the instance copy (GET, Stat-gated so an absent routine diffs as pure add/del) vs the mirror or a --from dir. {unified}. New internal/udiff (LCS, 3-line context) ported byte-identical from m-ydb. - sync rm : DeleteDoc on the instance + remove mirror file + manifest entry; honors --dry-run. {removed}. Already-absent is reported, not an error. - push --from DIR: push routines from an arbitrary directory (incl. fresh creates), staged into the mirror so the conflict-check / single-writer lock / compile path runs unchanged; the up-to-date check reads the --from copy so --dry-run is accurate. - bare-name --filter: the glob matches the extension-stripped routine name (DG*/DGREG select DGREG.mac; *.mac never matches), parity with m-ydb source.Match and driver-contract §5.2. - caps advertises all 8 sync verbs (honest gate; golden regenerated); meta schema picks up diff/rm automatically. Tests: unit tier (fake/rw Atelier) for diff (incl. missing-instance), rm (dry-run + delete), push --from (dry-run + fresh create), and bare-name match. Adds gated TestSyncAxis_RealEngine (M_IRIS_IT, make test-it) round-tripping an ephemeral zzMIRISIT scratch routine; the verbs ride GET/PUT/DELETE doc + docnames already validated against IRIS CE 2026.1 in M1 — the gated round-trip was not run this session (container down, docker/curl sandbox-blocked). go test -race / vet / gofmt clean. SDK unchanged (diff/rm/push results are driver-local shapes already in contract §5.2); both drivers stay pinned to m-driver-sdk v0.2.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 2 +- commands.go | 11 +- diff.go | 102 ++++++++++++++ diff_test.go | 79 +++++++++++ docs/m-iris-driver-status.md | 39 +++++- internal/driver/caps.go | 2 +- internal/driver/testdata/caps.golden.json | 4 +- internal/udiff/udiff.go | 161 ++++++++++++++++++++++ internal/udiff/udiff_test.go | 52 +++++++ main.go | 23 ++-- match_test.go | 32 +++++ push.go | 101 +++++++++++--- pushfrom_test.go | 73 ++++++++++ rm.go | 97 +++++++++++++ rm_test.go | 98 +++++++++++++ sync_integration_test.go | 122 ++++++++++++++++ 16 files changed, 963 insertions(+), 35 deletions(-) create mode 100644 diff.go create mode 100644 diff_test.go create mode 100644 internal/udiff/udiff.go create mode 100644 internal/udiff/udiff_test.go create mode 100644 match_test.go create mode 100644 pushfrom_test.go create mode 100644 rm.go create mode 100644 rm_test.go create mode 100644 sync_integration_test.go diff --git a/Makefile b/Makefile index 0ceddb8..9b41ee1 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ IRIS_PASSWORD ?= testsys test-it: M_IRIS_IT=1 M_IRIS_BASE_URL=$(IRIS_BASE_URL) M_IRIS_NAMESPACE=$(IRIS_NAMESPACE) \ M_IRIS_USER=$(IRIS_USER) M_IRIS_PASSWORD=$(IRIS_PASSWORD) \ - go test $(GOFLAGS) -count=1 -run RealEngine ./internal/remote/ -v + go test $(GOFLAGS) -count=1 -run RealEngine . ./internal/remote/ -v tidy: go mod tidy diff --git a/commands.go b/commands.go index 0ffc797..fa3acf7 100644 --- a/commands.go +++ b/commands.go @@ -437,13 +437,15 @@ func scopeManifest(man *manifest.Manifest, glob, pkg string) ([]string, error) { } // match reports whether docname passes the package prefix and glob filter. -// An empty pkg/glob matches everything. +// An empty pkg/glob matches everything. The --filter glob is matched against the +// extension-stripped bare name (driver-contract §5.2, parity with m-ydb), so +// "DG*"/"DGREG" select DGREG.mac but "*.mac" never matches. func match(docname, glob, pkg string) (bool, error) { if pkg != "" && !strings.HasPrefix(docname, pkg) { return false, nil } if glob != "" { - ok, err := path.Match(glob, docname) + ok, err := path.Match(glob, bareName(docname)) if err != nil { return false, fmt.Errorf("invalid --filter %q: %w", glob, err) } @@ -452,6 +454,11 @@ func match(docname, glob, pkg string) (bool, error) { return true, nil } +// bareName strips a routine's type extension: "DGREG.mac" → "DGREG". +func bareName(docname string) string { + return strings.TrimSuffix(docname, path.Ext(docname)) +} + func docNames(docs []atelier.DocName) []string { names := make([]string, 0, len(docs)) for _, d := range docs { diff --git a/diff.go b/diff.go new file mode 100644 index 0000000..285534e --- /dev/null +++ b/diff.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/atelier" + "github.com/vista-cloud-dev/m-iris/internal/config" + "github.com/vista-cloud-dev/m-iris/internal/udiff" +) + +// syncDiffCmd shows a unified diff of one routine: the instance copy (over +// Atelier) versus the local mirror — or a --from directory. It is read-only on +// both sides (driver-contract §5.2: `{ unified }`). A side absent on the +// instance or on disk is treated as empty, so the diff renders a pure addition +// or deletion rather than erroring. +type syncDiffCmd struct { + Name string `arg:"" help:"Routine to diff (bare name or NAME.mac)."` + From string `help:"Compare the instance against this directory instead of the mirror." placeholder:"DIR"` +} + +type syncDiffResult struct { + Unified string `json:"unified"` +} + +func (c *syncDiffCmd) Run(cc *clikit.Context, conn *config.Conn) error { + if err := conn.Validate(config.Need{Network: true}); err != nil { + return usageErr(err) + } + name := routineFile(c.Name, conn.Type) + ctx := context.Background() + + acfg, err := conn.Atelier() + if err != nil { + return usageErr(err) + } + client, err := atelier.New(acfg) + if err != nil { + return runtimeErr(err) + } + + // Instance side: fetch only if the routine exists (Stat distinguishes + // not-found cleanly), so an absent routine diffs as empty rather than error. + var instLines []string + if _, exists, sErr := client.Stat(ctx, name); sErr != nil { + return runtimeErr(sErr) + } else if exists { + doc, dErr := client.GetDoc(ctx, name) + if dErr != nil { + return runtimeErr(dErr) + } + instLines = normalizeLines(doc.Content) + } + + // Local side: the mirror file, or a file under --from. + localPath := conn.Layout().RoutinePath(name) + bLabel := "mirror/" + name + if c.From != "" { + localPath = filepath.Join(c.From, name) + bLabel = filepath.Join(c.From, name) + } + localBytes, lErr := os.ReadFile(localPath) + if lErr != nil && !os.IsNotExist(lErr) { + return runtimeErr(lErr) + } + + u := udiff.Unified("instance/"+name, bLabel, instLines, udiff.SplitLines(string(localBytes))) + + return cc.Result(syncDiffResult{Unified: u}, func() { + if u == "" { + fmt.Fprintln(cc.Stdout, cc.Success(name+": no differences")) + return + } + fmt.Fprint(cc.Stdout, u) + }) +} + +// normalizeLines strips any stray CR a server line carries, so line-ending +// differences don't show up as diffs (parity with mirror.WriteRoutine). +func normalizeLines(lines []string) []string { + out := make([]string, len(lines)) + for i, l := range lines { + out[i] = strings.TrimRight(l, "\r\n") + } + return out +} + +// routineFile normalizes a routine argument to its docname: a bare "DGREG" +// becomes "DGREG.", while an argument that already carries a routine +// extension (.mac/.int/.inc) is used verbatim. +func routineFile(name, typ string) string { + for _, ext := range []string{".mac", ".int", ".inc"} { + if strings.HasSuffix(name, ext) { + return name + } + } + return name + "." + typ +} diff --git a/diff_test.go b/diff_test.go new file mode 100644 index 0000000..c9443db --- /dev/null +++ b/diff_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/vista-cloud-dev/m-iris/internal/config" +) + +// diffResultOf runs `sync diff` and decodes the {unified} envelope. +func diffResultOf(t *testing.T, c *syncDiffCmd, conn *config.Conn) syncDiffResult { + t.Helper() + cc, buf := jsonCtx() + if err := c.Run(cc, conn); err != nil { + t.Fatalf("diff: %v", err) + } + var env struct { + Data syncDiffResult `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, buf.String()) + } + return env.Data +} + +func TestSyncDiff(t *testing.T) { + content := map[string][]string{"DGREG.mac": {"DGREG ;reg", " w 1", " q"}} + ts := map[string]string{"DGREG.mac": "t1"} + srv := fakeAtelier(content, ts) + defer srv.Close() + + conn := &config.Conn{ + BaseURL: srv.URL + "/api/atelier/v1/", Instance: "i", Namespace: "VISTA", + Mirror: t.TempDir(), Concurrency: 2, Type: "mac", + } + // Pull so the mirror holds the instance copy. + cc, _ := jsonCtx() + if err := (&pullCmd{}).Run(cc, conn); err != nil { + t.Fatalf("pull: %v", err) + } + + // Identical → empty unified diff. + if got := diffResultOf(t, &syncDiffCmd{Name: "DGREG"}, conn); got.Unified != "" { + t.Errorf("identical diff should be empty, got:\n%s", got.Unified) + } + + // Edit the mirror file → diff surfaces the change (instance vs mirror). + path := conn.Layout().RoutinePath("DGREG.mac") + if err := os.WriteFile(path, []byte("DGREG ;reg\n w 2\n q\n"), 0o644); err != nil { + t.Fatal(err) + } + got := diffResultOf(t, &syncDiffCmd{Name: "DGREG"}, conn) + for _, want := range []string{"--- instance/DGREG.mac", "+++ mirror/DGREG.mac", "- w 1", "+ w 2"} { + if !strings.Contains(got.Unified, want) { + t.Errorf("missing %q in unified diff:\n%s", want, got.Unified) + } + } +} + +func TestSyncDiffMissingInstance(t *testing.T) { + // Instance has no such routine; the mirror/--from has one → pure addition. + srv := fakeAtelier(map[string][]string{}, map[string]string{}) + defer srv.Close() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "NEW.mac"), []byte("NEW ;x\n q\n"), 0o644); err != nil { + t.Fatal(err) + } + conn := &config.Conn{ + BaseURL: srv.URL + "/api/atelier/v1/", Instance: "i", Namespace: "VISTA", + Mirror: t.TempDir(), Type: "mac", + } + got := diffResultOf(t, &syncDiffCmd{Name: "NEW", From: dir}, conn) + if !strings.Contains(got.Unified, "+NEW ;x") { + t.Errorf("expected pure addition for a routine absent on the instance, got:\n%s", got.Unified) + } +} diff --git a/docs/m-iris-driver-status.md b/docs/m-iris-driver-status.md index 6fec4a6..807f69c 100644 --- a/docs/m-iris-driver-status.md +++ b/docs/m-iris-driver-status.md @@ -34,6 +34,38 @@ Legend: ☑ done · ◐ in progress · ☐ not started - ☐ local/docker lifecycle (container / `iris start`/`iris stop`) — needs the session-transport command seam (lands with M3 local+docker exec). +## M2 — sync axis ☑ (plan §5 task 6) + +The sync axis reaches 8-verb parity with m-ydb. The irissync source verbs were +already regrouped under `sync` in M0 (`list`/`pull`/`status`/`verify`/`push`/ +`deploy`); M2 adds the inspect/delete/author verbs and tightens the filter. + +- ☑ `sync diff [--from DIR]` — unified diff of the instance copy (GET + over Atelier, gated by `Stat` so an absent routine diffs as a pure + addition/deletion) vs the local mirror, or a `--from` directory. `{ unified }`. + Diff engine is `internal/udiff` (LCS, single hunk, 3-line context), ported + byte-identical from m-ydb. +- ☑ `sync rm ` — removes a routine from the instance (`DeleteDoc`), the + mirror, and the manifest; honors `--dry-run`. `{ removed }`. A routine already + absent is reported, not an error. +- ☑ `push --from DIR` — pushes routines from an arbitrary directory (incl. fresh + creates the manifest has never seen). Content is staged into the mirror, so + push's conflict-check / single-writer lock / compile-on-import path runs + unchanged; the up-to-date short-circuit reads the `--from` copy so `--dry-run` + is accurate. +- ☑ Bare-name `--filter` — the glob matches the extension-stripped routine name + (`DG*`/`DGREG` select `DGREG.mac`; `*.mac` never matches), parity with m-ydb + `source.Match` and driver-contract §5.2. +- ☑ `caps` advertises all 8 sync verbs (honest gate; golden regenerated); the + `meta schema` tree picks up diff/rm automatically. +- ◐ Real-engine tier: `TestSyncAxis_RealEngine` (package `main`, gated on + `M_IRIS_IT=1` + `M_IRIS_*`, `make test-it`) pulls/pushes/diffs/removes an + ephemeral `zzMIRISIT` scratch routine against a live IRIS, self-cleaning. The + verbs ride the GET/PUT/DELETE doc + docnames endpoints already validated + against IRIS CE 2026.1 in M1; the gated round-trip was not executed this + session (the disposable `m-test-iris` container was down and docker/curl were + sandbox-blocked) — run `make test-it` once the container is up. + ## Remote spike (plan §5 task 8) — substrate built, real-engine green gated ◐ The remote substrate is the whole-cloth de-risking item (risk B2): Atelier has no @@ -60,8 +92,11 @@ runner class. Built and **unit-proven**; real-engine green runs in CI. - `do @ref` / `set @ref=value` name-indirection over a global reference string. ## Next -- M1 lifecycle + health probes + `doctor`; wire the `local`/`docker` (`iris - session`) Transport strategies alongside `remote`. +- **M3 exec** — wire the remote runner Transport (already built + spiked) into + `exec load`/`run`/`eval`/`abort`; parse IRIS faults into the §7 `engineError`; + `--prefix` ephemeral runs. Then build the `local`/`docker` (`iris session`) + Transport strategies (which also unblock the deferred docker/local + `lifecycle up`/`down`). - Phase-0 SDK reconciliation with m-ydb (see below). ## SDK reconciliation note (Phase-0) diff --git a/internal/driver/caps.go b/internal/driver/caps.go index 8118674..6baed11 100644 --- a/internal/driver/caps.go +++ b/internal/driver/caps.go @@ -23,7 +23,7 @@ func CapsDoc() mdriver.Caps { Axes: mdriver.Axes{ // M0 — meta + the existing irissync source verbs, regrouped under sync. Meta: []string{"caps", "version", "info", "schema", "doctor"}, - Sync: []string{"list", "pull", "status", "verify", "push", "deploy"}, + Sync: []string{"list", "pull", "status", "verify", "push", "deploy", "diff", "rm"}, // M1 — lifecycle + health probes. provision/destroy are advertised but // report unsupported (exit 7) on the remote transport (risk B4). Lifecycle: []string{"up", "down", "restart", "status", "wait", "provision", "destroy"}, diff --git a/internal/driver/testdata/caps.golden.json b/internal/driver/testdata/caps.golden.json index 727c65a..f4bdf5e 100644 --- a/internal/driver/testdata/caps.golden.json +++ b/internal/driver/testdata/caps.golden.json @@ -22,7 +22,9 @@ "status", "verify", "push", - "deploy" + "deploy", + "diff", + "rm" ], "meta": [ "caps", diff --git a/internal/udiff/udiff.go b/internal/udiff/udiff.go new file mode 100644 index 0000000..331ab45 --- /dev/null +++ b/internal/udiff/udiff.go @@ -0,0 +1,161 @@ +// Package udiff produces a unified diff of two line slices — enough for the +// `sync diff` verb (driver-contract §5.2: `{ unified }`). Routines are small, +// so an LCS DP over lines is fine; the output is a single hunk with up to three +// lines of surrounding context, which is valid unified-diff format. +package udiff + +import ( + "fmt" + "strings" +) + +const context = 3 + +// SplitLines splits s into lines, dropping a single trailing newline so a +// normalized file ("a\nb\n") yields exactly its lines (["a","b"]). +func SplitLines(s string) []string { + if s == "" { + return nil + } + s = strings.TrimSuffix(s, "\n") + return strings.Split(s, "\n") +} + +type op struct { + tag byte // ' ' equal, '-' removed (in a), '+' added (in b) + text string +} + +// Unified returns the unified diff transforming a → b, or "" if they are equal. +func Unified(aName, bName string, a, b []string) string { + ops := diffOps(a, b) + + firstChange, lastChange := -1, -1 + for k, o := range ops { + if o.tag != ' ' { + if firstChange < 0 { + firstChange = k + } + lastChange = k + } + } + if firstChange < 0 { + return "" // identical + } + + // Per-op source line numbers (1-based). + aLineOf := make([]int, len(ops)) + bLineOf := make([]int, len(ops)) + a1, b1 := 1, 1 + for k, o := range ops { + switch o.tag { + case ' ': + aLineOf[k], bLineOf[k] = a1, b1 + a1++ + b1++ + case '-': + aLineOf[k] = a1 + a1++ + case '+': + bLineOf[k] = b1 + b1++ + } + } + + lo := firstChange - context + if lo < 0 { + lo = 0 + } + hi := lastChange + context + if hi > len(ops)-1 { + hi = len(ops) - 1 + } + + aStart, aCount, bStart, bCount := 0, 0, 0, 0 + for k := lo; k <= hi; k++ { + switch ops[k].tag { + case ' ': + if aCount == 0 { + aStart = aLineOf[k] + } + if bCount == 0 { + bStart = bLineOf[k] + } + aCount++ + bCount++ + case '-': + if aCount == 0 { + aStart = aLineOf[k] + } + aCount++ + case '+': + if bCount == 0 { + bStart = bLineOf[k] + } + bCount++ + } + } + + var sb strings.Builder + fmt.Fprintf(&sb, "--- %s\n", aName) + fmt.Fprintf(&sb, "+++ %s\n", bName) + fmt.Fprintf(&sb, "@@ -%s +%s @@\n", rangeSpec(aStart, aCount), rangeSpec(bStart, bCount)) + for k := lo; k <= hi; k++ { + sb.WriteByte(ops[k].tag) + sb.WriteString(ops[k].text) + sb.WriteByte('\n') + } + return sb.String() +} + +// rangeSpec renders the "start,count" half of a hunk header (count omitted when +// it is 1, per unified-diff convention; start 0 for an empty side). +func rangeSpec(start, count int) string { + if count == 1 { + return fmt.Sprint(start) + } + return fmt.Sprintf("%d,%d", start, count) +} + +// diffOps computes an edit script (LCS-based) transforming a → b. +func diffOps(a, b []string) []op { + n, m := len(a), len(b) + dp := make([][]int, n+1) + for i := range dp { + dp[i] = make([]int, m+1) + } + for i := n - 1; i >= 0; i-- { + for j := m - 1; j >= 0; j-- { + if a[i] == b[j] { + dp[i][j] = dp[i+1][j+1] + 1 + } else if dp[i+1][j] >= dp[i][j+1] { + dp[i][j] = dp[i+1][j] + } else { + dp[i][j] = dp[i][j+1] + } + } + } + var ops []op + i, j := 0, 0 + for i < n && j < m { + switch { + case a[i] == b[j]: + ops = append(ops, op{' ', a[i]}) + i++ + j++ + case dp[i+1][j] >= dp[i][j+1]: + ops = append(ops, op{'-', a[i]}) + i++ + default: + ops = append(ops, op{'+', b[j]}) + j++ + } + } + for ; i < n; i++ { + ops = append(ops, op{'-', a[i]}) + } + for ; j < m; j++ { + ops = append(ops, op{'+', b[j]}) + } + return ops +} diff --git a/internal/udiff/udiff_test.go b/internal/udiff/udiff_test.go new file mode 100644 index 0000000..4e6e767 --- /dev/null +++ b/internal/udiff/udiff_test.go @@ -0,0 +1,52 @@ +package udiff + +import ( + "strings" + "testing" +) + +func TestUnified_Change(t *testing.T) { + a := []string{"line1", "line2", "line3"} + b := []string{"line1", "EDITED", "line3"} + got := Unified("instance/FOO.m", "mirror/FOO.m", a, b) + + for _, want := range []string{ + "--- instance/FOO.m", + "+++ mirror/FOO.m", + "@@ -1,3 +1,3 @@", + " line1", + "-line2", + "+EDITED", + " line3", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } +} + +func TestUnified_Identical(t *testing.T) { + a := []string{"x", "y"} + if got := Unified("a", "b", a, a); got != "" { + t.Errorf("identical inputs should diff empty, got:\n%s", got) + } +} + +func TestUnified_PureAddition(t *testing.T) { + got := Unified("a", "b", []string{"one"}, []string{"one", "two"}) + if !strings.Contains(got, "+two") { + t.Errorf("expected +two, got:\n%s", got) + } +} + +func TestSplitLines(t *testing.T) { + if got := SplitLines("a\nb\n"); len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Errorf("SplitLines = %v, want [a b]", got) + } + if got := SplitLines("a\nb"); len(got) != 2 { // no trailing newline + t.Errorf("SplitLines no-trailing = %v, want 2 lines", got) + } + if got := SplitLines(""); len(got) != 0 { + t.Errorf("SplitLines empty = %v, want 0", got) + } +} diff --git a/main.go b/main.go index 43f6f19..f2bce30 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,8 @@ // m-iris sync verify re-hash mirror files against the manifest (exit 3) // m-iris sync push write edited routines back to IRIS (the sole DB writer) // m-iris sync deploy install a routine-source library (--prune true-sync) +// m-iris sync diff unified diff of one routine: instance vs mirror/--from +// m-iris sync rm remove a routine from instance + mirror + manifest // // Later milestones add the lifecycle, exec, data, cover, admin, and native // axes; caps grows to advertise each as it lands (caps is honest by @@ -45,7 +47,7 @@ type CLI struct { Meta metaCmd `cmd:"" help:"Introspection + power tools: caps / info / version / schema."` Lifecycle lifecycleCmd `cmd:"" help:"Manage the engine instance: up / down / restart / status / wait / provision / destroy."` - Sync syncCmd `cmd:"" help:"Source axis: routine source ↔ instance (list / pull / status / verify / push / deploy)."` + Sync syncCmd `cmd:"" help:"Source axis: routine source ↔ instance (list / pull / status / verify / push / deploy / diff / rm)."` InstallCompletions kongplete.InstallCompletions `cmd:"" help:"Install shell tab-completions."` } @@ -54,15 +56,18 @@ type CLI struct { // verbs, regrouped. The read verbs (list/pull/status/verify) are safe by // construction (every IRIS operation is a GET; writes go only to the local // mirror); push is the opt-in write path and the sole DB writer (locked + -// conflict-checked); deploy installs a routine-source library. M2 adds diff/rm -// and the bare-name --filter. +// conflict-checked); deploy installs a routine-source library; diff/rm are the +// inspect/delete counterparts. The --filter glob is bare-name (extension +// stripped), matching m-ydb (driver-contract §5.2). type syncCmd struct { - List listCmd `cmd:"" help:"List server routine docnames (no writes) — connectivity + inventory."` - Pull pullCmd `cmd:"" help:"Materialize IRIS routine source → mirror, incremental via the manifest."` - Status statusCmd `cmd:"" help:"Diff server vs. local manifest: new / changed / deleted (exit 3 on drift)."` - Verify verifyCmd `cmd:"" help:"Re-hash mirror files against the manifest (exit 3 on mismatch)."` - Push pushCmd `cmd:"" help:"Write edited routines back to IRIS (PUT + compile) — the sole DB writer; conflict-checked + single-writer-locked (exit 4 on refusal)."` - Deploy deployCmd `cmd:"" help:"Install a routine-source library (e.g. m-stdlib/src) into a namespace over Atelier (PUT + compile); --prune for a true sync."` + List listCmd `cmd:"" help:"List server routine docnames (no writes) — connectivity + inventory."` + Pull pullCmd `cmd:"" help:"Materialize IRIS routine source → mirror, incremental via the manifest."` + Status statusCmd `cmd:"" help:"Diff server vs. local manifest: new / changed / deleted (exit 3 on drift)."` + Verify verifyCmd `cmd:"" help:"Re-hash mirror files against the manifest (exit 3 on mismatch)."` + Push pushCmd `cmd:"" help:"Write edited routines back to IRIS (PUT + compile) — the sole DB writer; conflict-checked + single-writer-locked (exit 4 on refusal)."` + Deploy deployCmd `cmd:"" help:"Install a routine-source library (e.g. m-stdlib/src) into a namespace over Atelier (PUT + compile); --prune for a true sync."` + Diff syncDiffCmd `cmd:"" help:"Unified diff of one routine: instance vs the local mirror (or --from DIR)."` + Rm syncRmCmd `cmd:"" help:"Remove a routine from the instance + mirror + manifest (honors --dry-run)."` } func main() { diff --git a/match_test.go b/match_test.go new file mode 100644 index 0000000..4a600a4 --- /dev/null +++ b/match_test.go @@ -0,0 +1,32 @@ +package main + +import "testing" + +// match's --filter glob is bare-name: the routine type extension (.mac/.int/.inc) +// is stripped before the glob is applied, matching m-ydb's source.Match and +// driver-contract §5.2. The --package prefix matches the full docname. +func TestMatchBareNameFilter(t *testing.T) { + cases := []struct { + docname, glob, pkg string + want bool + }{ + {"DGREG.mac", "DG*", "", true}, // prefix glob on the bare name + {"DGREG.mac", "DGREG", "", true}, // exact bare name (no extension) + {"DGREG.mac", "*.mac", "", false}, // bare-name glob: ".mac" is stripped, never matches + {"XUSER.mac", "DG*", "", false}, // non-matching prefix + {"DGREG.int", "DG*", "", true}, // works across routine types + {"DGREG.mac", "", "DG", true}, // package prefix on the docname + {"XUSER.mac", "", "DG", false}, // package prefix excludes + {"DGREG.mac", "DG*", "DG", true}, // both gates pass + {"DGREG.mac", "", "", true}, // no filter matches everything + } + for _, c := range cases { + got, err := match(c.docname, c.glob, c.pkg) + if err != nil { + t.Fatalf("match(%q,%q,%q): %v", c.docname, c.glob, c.pkg, err) + } + if got != c.want { + t.Errorf("match(%q,%q,%q) = %v, want %v", c.docname, c.glob, c.pkg, got, c.want) + } + } +} diff --git a/push.go b/push.go index c2d8cc8..ef4f540 100644 --- a/push.go +++ b/push.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "sort" "strings" "time" @@ -35,6 +36,7 @@ import ( // generate the read-only .int; the manifest entry is refreshed to the new // server timestamp so the next status/verify is accurate. type pushCmd struct { + From string `help:"Push routines from this directory instead of the mirror (staged into the mirror on success)." placeholder:"DIR"` Force bool `help:"Push even if the server copy changed since pull, or is held by another writer (override the conflict-check and detect-and-defer)."` LockTTL time.Duration `name:"lock-ttl" default:"15m" help:"Reclaim a stale push lock older than this."` NoCompile bool `name:"no-compile" help:"Skip the post-import compile (compile is on by default)."` @@ -76,20 +78,35 @@ func (c *pushCmd) Run(cc *clikit.Context, conn *config.Conn) error { "no manifest at "+layout.ManifestPath()+"; run 'm-iris sync pull' first", "push requires a pulled mirror as its conflict-check basis") } - // Candidate routines: the manifest's docnames, filtered, that have a mirror - // file on disk. push writes what is in the mirror — it does not invent - // routines from thin air. - names, err := scopeManifest(man, conn.Filter, conn.Package) - if err != nil { - return usageErr(err) - } - present := make([]string, 0, len(names)) - for _, n := range names { - if _, statErr := os.Stat(layout.RoutinePath(n)); statErr == nil { - present = append(present, n) + // Candidate routines. By default: the manifest's docnames, filtered, that + // have a mirror file on disk — push writes what is in the mirror, it does + // not invent routines. With --from: the routine files in that directory + // (filtered), which may include routines the manifest has never seen (fresh + // creates); their content is staged into the mirror before the write so the + // rest of push (conflict-check / compile / manifest) runs unchanged. + var present []string + if c.From != "" { + present, err = dirRoutines(c.From, conn.Filter, conn.Package, conn.Type) + if err != nil { + return usageErr(err) + } + if !conn.DryRun { + if err := stageDir(c.From, layout, present); err != nil { + return runtimeErr(err) + } + } + } else { + names, scErr := scopeManifest(man, conn.Filter, conn.Package) + if scErr != nil { + return usageErr(scErr) } + for _, n := range names { + if _, statErr := os.Stat(layout.RoutinePath(n)); statErr == nil { + present = append(present, n) + } + } + sort.Strings(present) } - sort.Strings(present) acfg, err := conn.Atelier() if err != nil { @@ -198,9 +215,13 @@ func (c *pushCmd) planPush(ctx context.Context, client *atelier.Client, layout m // Up-to-date short-circuit: the local file already matches the server. // Compare the recorded manifest hash against the live server state via // the timestamp — if nothing changed locally and the server matches, no - // PUT is needed. We detect "nothing to push" by comparing the on-disk - // hash to the manifest entry (an unchanged file the server still matches). - if conf.Kind == manifest.ConflictNone && exists && localMatchesManifest(layout, man, name) && stat.TS == man.Routines[name].ServerTS { + // PUT is needed. The "local" file is the --from copy when given, so the + // dry-run plan reflects the directory being pushed, not a stale mirror. + srcPath := layout.RoutinePath(name) + if c.From != "" { + srcPath = filepath.Join(c.From, name) + } + if conf.Kind == manifest.ConflictNone && exists && pathMatchesManifest(srcPath, man, name) && stat.TS == man.Routines[name].ServerTS { p.upToDate = append(p.upToDate, name) continue } @@ -332,20 +353,62 @@ func (c *pushCmd) emit(cc *clikit.Context, conn *config.Conn, layout mirror.Layo return nil } -// localMatchesManifest reports whether the on-disk routine still hashes to the -// manifest entry (i.e. it was not edited since pull). -func localMatchesManifest(layout mirror.Layout, man *manifest.Manifest, name string) bool { +// pathMatchesManifest reports whether the file at path still hashes to the +// manifest entry for name (i.e. it was not edited since pull). +func pathMatchesManifest(path string, man *manifest.Manifest, name string) bool { e, ok := man.Routines[name] if !ok { return false } - sum, n, err := mirror.HashFile(layout.RoutinePath(name)) + sum, n, err := mirror.HashFile(path) if err != nil { return false } return sum == e.SHA256 && n == e.Bytes } +// dirRoutines lists the routine docnames in dir (files whose extension matches +// the configured routine type) whose bare name passes the --filter glob and +// --package prefix. The returned names are sorted docnames (e.g. "DGREG.mac"). +func dirRoutines(dir, glob, pkg, typ string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + ext := "." + typ + var names []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ext) { + continue + } + ok, mErr := match(e.Name(), glob, pkg) + if mErr != nil { + return nil, mErr + } + if ok { + names = append(names, e.Name()) + } + } + sort.Strings(names) + return names, nil +} + +// stageDir copies each named routine from dir into the mirror (normalized, +// atomic), so push's mirror-based read/conflict/manifest path runs unchanged +// for a --from push. +func stageDir(dir string, layout mirror.Layout, names []string) error { + for _, name := range names { + lines, _, _, err := readRoutine(filepath.Join(dir, name)) + if err != nil { + return fmt.Errorf("read %s: %w", name, err) + } + if _, err := mirror.WriteRoutine(layout.RoutinePath(name), lines); err != nil { + return fmt.Errorf("stage %s: %w", name, err) + } + } + return nil +} + // updMap builds docname → updatable from a docnames listing. The Atelier `upd` // flag is true when the server will accept a write to that document. func updMap(docs []atelier.DocName) map[string]bool { diff --git a/pushfrom_test.go b/pushfrom_test.go new file mode 100644 index 0000000..b2ff249 --- /dev/null +++ b/pushfrom_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestPushFromDir proves `push --from DIR` pushes routines from an arbitrary +// directory (not the mirror), landing them on the instance + mirror + manifest, +// including a routine the instance has never seen (a fresh create). +func TestPushFromDir(t *testing.T) { + fake := newRWAtelier(map[string][]string{}, map[string]string{}) + srv := fake.start() + defer srv.Close() + + conn := pullThenConn(t, srv) // empty server → empty mirror + manifest + layout := conn.Layout() + + from := t.TempDir() + if err := os.WriteFile(filepath.Join(from, "NEW.mac"), []byte("NEW ;fresh\n q\n"), 0o644); err != nil { + t.Fatal(err) + } + + // Dry run: reports the plan, writes nothing. + cc, buf := jsonCtx() + if err := (&pushCmd{From: from}).Run(cc, withDryRun(conn)); err != nil { + t.Fatalf("dry-run push --from: %v", err) + } + var dry struct { + Data pushResult `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &dry); err != nil { + t.Fatalf("decode: %v\n%s", err, buf.String()) + } + if !dry.Data.DryRun || len(dry.Data.Items) != 1 { + t.Errorf("dry run: dryRun=%v items=%v", dry.Data.DryRun, dry.Data.Items) + } + if len(fake.puts) != 0 { + t.Errorf("dry run must not PUT, got %v", fake.puts) + } + if _, err := os.Stat(layout.RoutinePath("NEW.mac")); !os.IsNotExist(err) { + t.Errorf("dry run must not stage the mirror file, err=%v", err) + } + + // Real push --from. + cc, buf = jsonCtx() + if err := (&pushCmd{From: from}).Run(cc, conn); err != nil { + t.Fatalf("push --from: %v", err) + } + var env struct { + Data pushResult `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, buf.String()) + } + if env.Data.Pushed != 1 { + t.Errorf("pushed = %d, want 1", env.Data.Pushed) + } + if got := strings.Join(fake.content["NEW.mac"], "\n"); got != "NEW ;fresh\n q" { + t.Errorf("instance content after push --from = %q", got) + } + // Mirror staged + manifest updated, so a follow-up verify is clean. + if _, err := os.Stat(layout.RoutinePath("NEW.mac")); err != nil { + t.Errorf("mirror file not staged: %v", err) + } + cc, _ = jsonCtx() + if err := (verifyCmd{}).Run(cc, conn); err != nil { + t.Fatalf("verify after push --from: %v (want clean)", err) + } +} diff --git a/rm.go b/rm.go new file mode 100644 index 0000000..80b367d --- /dev/null +++ b/rm.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/atelier" + "github.com/vista-cloud-dev/m-iris/internal/config" + "github.com/vista-cloud-dev/m-iris/internal/manifest" +) + +// syncRmCmd removes one routine from the instance (DELETE over Atelier), the +// local mirror, and the manifest — the delete counterpart to push +// (driver-contract §5.2: `{ removed }`). A routine already absent on the +// instance is reported but not an error (the desired end state). --dry-run +// reports the plan without touching anything. +type syncRmCmd struct { + Name string `arg:"" help:"Routine to remove (bare name or NAME.mac)."` +} + +type syncRmResult struct { + Removed []string `json:"removed"` + DryRun bool `json:"dryRun,omitempty"` +} + +func (c *syncRmCmd) Run(cc *clikit.Context, conn *config.Conn) error { + if err := conn.Validate(config.Need{Network: true, Mirror: true}); err != nil { + return usageErr(err) + } + name := routineFile(c.Name, conn.Type) + ctx := context.Background() + layout := conn.Layout() + + acfg, err := conn.Atelier() + if err != nil { + return usageErr(err) + } + client, err := atelier.New(acfg) + if err != nil { + return runtimeErr(err) + } + + // A routine counts as removable if it exists on the instance or in the mirror. + _, onInstance, sErr := client.Stat(ctx, name) + if sErr != nil { + return runtimeErr(sErr) + } + _, statErr := os.Stat(layout.RoutinePath(name)) + inMirror := statErr == nil + exists := onInstance || inMirror + + var removed []string + if exists { + removed = []string{name} + } + + if conn.DryRun { + return cc.Result(syncRmResult{Removed: nonNil(removed), DryRun: true}, func() { + cc.Title("rm plan (dry run)") + fmt.Fprintln(cc.Stdout, " would remove "+strings.Join(nonNil(removed), ", ")) + }) + } + + if exists { + if onInstance { + if err := client.DeleteDoc(ctx, name); err != nil { + return runtimeErr(err) + } + } + if err := os.Remove(layout.RoutinePath(name)); err != nil && !os.IsNotExist(err) { + return runtimeErr(err) + } + man, mErr := manifest.Load(layout.ManifestPath()) + if mErr != nil { + return runtimeErr(mErr) + } + if man != nil { + if _, ok := man.Routines[name]; ok { + delete(man.Routines, name) + if err := manifest.Save(layout.ManifestPath(), man); err != nil { + return runtimeErr(err) + } + } + } + } + + return cc.Result(syncRmResult{Removed: nonNil(removed)}, func() { + if len(removed) == 0 { + fmt.Fprintln(cc.Stdout, cc.Warning(name+": not present on the instance or in the mirror")) + return + } + fmt.Fprintln(cc.Stdout, cc.Success("removed "+name)) + }) +} diff --git a/rm_test.go b/rm_test.go new file mode 100644 index 0000000..91aa1ff --- /dev/null +++ b/rm_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + + "github.com/vista-cloud-dev/m-iris/internal/config" +) + +// fakeAtelierRm wraps fakeAtelier's read endpoints and records DELETEs so a test +// can assert the instance copy was removed. +func fakeAtelierRm(content map[string][]string, ts map[string]string, deleted *[]string) *httptest.Server { + inner := fakeAtelier(content, ts) + var mu sync.Mutex + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/doc/") { + name := r.URL.Path[strings.Index(r.URL.Path, "/doc/")+len("/doc/"):] + mu.Lock() + *deleted = append(*deleted, name) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":{"errors":[]},"result":{}}`)) + return + } + inner.Config.Handler.ServeHTTP(w, r) + })) +} + +func rmResultOf(t *testing.T, c *syncRmCmd, conn *config.Conn) syncRmResult { + t.Helper() + cc, buf := jsonCtx() + if err := c.Run(cc, conn); err != nil { + t.Fatalf("rm: %v", err) + } + var env struct { + Data syncRmResult `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, buf.String()) + } + return env.Data +} + +func TestSyncRm(t *testing.T) { + content := map[string][]string{"DGREG.mac": {"DGREG ;reg", " q"}} + ts := map[string]string{"DGREG.mac": "t1"} + var deleted []string + srv := fakeAtelierRm(content, ts, &deleted) + defer srv.Close() + + conn := &config.Conn{ + BaseURL: srv.URL + "/api/atelier/v1/", Instance: "i", Namespace: "VISTA", + Mirror: t.TempDir(), Concurrency: 2, Type: "mac", + } + cc, _ := jsonCtx() + if err := (&pullCmd{}).Run(cc, conn); err != nil { + t.Fatalf("pull: %v", err) + } + mirrorFile := conn.Layout().RoutinePath("DGREG.mac") + if _, err := os.Stat(mirrorFile); err != nil { + t.Fatalf("precondition: mirror file missing: %v", err) + } + + // Dry run removes nothing. + dry := rmResultOf(t, &syncRmCmd{Name: "DGREG"}, withDryRun(conn)) + if len(dry.Removed) != 1 || !dry.DryRun { + t.Errorf("dry run: removed=%v dryRun=%v", dry.Removed, dry.DryRun) + } + if len(deleted) != 0 { + t.Errorf("dry run must not DELETE on the instance, got %v", deleted) + } + if _, err := os.Stat(mirrorFile); err != nil { + t.Errorf("dry run must not remove the mirror file: %v", err) + } + + // Real rm removes from instance + mirror + manifest. + got := rmResultOf(t, &syncRmCmd{Name: "DGREG"}, conn) + if len(got.Removed) != 1 || got.Removed[0] != "DGREG.mac" { + t.Errorf("removed = %v, want [DGREG.mac]", got.Removed) + } + if len(deleted) != 1 || deleted[0] != "DGREG.mac" { + t.Errorf("instance DELETE = %v, want [DGREG.mac]", deleted) + } + if _, err := os.Stat(mirrorFile); !os.IsNotExist(err) { + t.Errorf("mirror file should be gone, err=%v", err) + } +} + +func withDryRun(conn *config.Conn) *config.Conn { + c := *conn + c.DryRun = true + return &c +} diff --git a/sync_integration_test.go b/sync_integration_test.go new file mode 100644 index 0000000..7979fe8 --- /dev/null +++ b/sync_integration_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/vista-cloud-dev/m-iris/internal/atelier" + "github.com/vista-cloud-dev/m-iris/internal/config" +) + +// TestSyncAxis_RealEngine validates the M2 source verbs added in this milestone +// — push --from, diff, rm — against a REAL IRIS over Atelier, using an ephemeral +// scratch routine (zzMIRISIT prefix) so it attaches to an existing namespace +// without clobbering anything (attached-mode cleanup, driver-plan §5 nuance). +// +// Gated: runs only with M_IRIS_IT=1 and an Atelier target in M_IRIS_* env — the +// same vars the driver uses. The fake-Atelier unit tests cover every commit; +// this real tier is `make test-it` / CI. push uses --no-compile so the test +// exercises the source round-trip (PUT/GET/DELETE) the new verbs ride, not the +// compiler. +// +// M_IRIS_IT=1 M_IRIS_BASE_URL=http://localhost:52774/api/atelier/v1/ \ +// M_IRIS_NAMESPACE=USER M_IRIS_USER=_SYSTEM M_IRIS_PASSWORD=testsys \ +// go test -run TestSyncAxis_RealEngine . -v +func TestSyncAxis_RealEngine(t *testing.T) { + if os.Getenv("M_IRIS_IT") != "1" { + t.Skip("set M_IRIS_IT=1 (+ M_IRIS_* connection env) to run the real-engine sync tier") + } + conn := &config.Conn{ + Transport: "remote", + BaseURL: envOrDefault("M_IRIS_BASE_URL", "http://localhost:52774/api/atelier/v1/"), + Namespace: envOrDefault("M_IRIS_NAMESPACE", "USER"), + Instance: "m-test-iris", + User: envOrDefault("M_IRIS_USER", "_SYSTEM"), + Password: envOrDefault("M_IRIS_PASSWORD", "testsys"), + Mirror: t.TempDir(), + Type: "mac", + Concurrency: 4, + Filter: "zzMIRISIT*", // scope every server listing to the scratch prefix + } + const bare = "zzMIRISITSCRATCH" + docname := bare + ".mac" + + acfg, err := conn.Atelier() + if err != nil { + t.Fatalf("atelier config: %v", err) + } + client, err := atelier.New(acfg) + if err != nil { + t.Fatalf("atelier client: %v", err) + } + ctx := context.Background() + cleanup := func() { _ = client.DeleteDoc(ctx, docname) } + cleanup() // drop a leftover from a prior failed run + t.Cleanup(cleanup) // and on the way out + + // Seed the manifest with a scoped pull (matches nothing yet → empty manifest). + cc, _ := jsonCtx() + if err := (&pullCmd{}).Run(cc, conn); err != nil { + t.Fatalf("seed pull: %v", err) + } + + // push --from: create the scratch routine on the instance + mirror + manifest. + from := t.TempDir() + if err := os.WriteFile(filepath.Join(from, docname), []byte(bare+" ;m-iris IT scratch\n quit\n"), 0o644); err != nil { + t.Fatal(err) + } + cc, buf := jsonCtx() + if err := (&pushCmd{From: from, NoCompile: true}).Run(cc, conn); err != nil { + t.Fatalf("push --from: %v\n%s", err, buf.String()) + } + var pe struct { + Data pushResult `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &pe); err != nil { + t.Fatalf("decode push: %v\n%s", err, buf.String()) + } + if pe.Data.Pushed != 1 { + t.Fatalf("pushed = %d, want 1\n%s", pe.Data.Pushed, buf.String()) + } + if _, exists, sErr := client.Stat(ctx, docname); sErr != nil || !exists { + t.Fatalf("scratch routine should exist on the instance: exists=%v err=%v", exists, sErr) + } + + // diff: edit the mirror copy → the instance↔mirror diff surfaces the change. + if err := os.WriteFile(conn.Layout().RoutinePath(docname), []byte(bare+" ;m-iris IT scratch\n ; edited\n quit\n"), 0o644); err != nil { + t.Fatal(err) + } + cc, buf = jsonCtx() + if err := (&syncDiffCmd{Name: bare}).Run(cc, conn); err != nil { + t.Fatalf("diff: %v", err) + } + var de struct { + Data syncDiffResult `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &de); err != nil { + t.Fatalf("decode diff: %v\n%s", err, buf.String()) + } + if !strings.Contains(de.Data.Unified, "+ ; edited") { + t.Errorf("diff should surface the local edit, got:\n%s", de.Data.Unified) + } + + // rm: delete from the instance + mirror + manifest; the instance copy is gone. + cc, _ = jsonCtx() + if err := (&syncRmCmd{Name: bare}).Run(cc, conn); err != nil { + t.Fatalf("rm: %v", err) + } + if _, exists, sErr := client.Stat(ctx, docname); sErr != nil || exists { + t.Errorf("scratch routine should be removed: exists=%v err=%v", exists, sErr) + } +} + +func envOrDefault(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} From 8c2f0103c7e8b608d3366d1572e816cca6e17395 Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Fri, 5 Jun 2026 23:55:14 -0400 Subject: [PATCH 09/24] m-iris: fix two real-IRIS-2026.1 bugs the fake tier missed (Stat 404, PutDoc rejection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the new gated TestSyncAxis_RealEngine against live IRIS CE 2026.1 surfaced two latent bugs in the Atelier client that the fake-transport unit tests could not: 1. Missing-doc detection — IRIS 2026.1 answers GET /doc/{name} for an absent document with a bare HTTP 404 (older servers embedded "does not exist"/#5002 in status.errors). isNotFound now also recognizes a 404 HTTPError, so Stat/DeleteDoc treat an absent routine as not-found (exists=false) instead of a hard error. Without this, push's conflict-check, sync diff, and sync rm all errored on any routine not yet on the instance. 2. Silent PUT rejection — a save-time rejection (e.g. #16021 Illegal Header Line on a modern .mac lacking a `ROUTINE name [Type=MAC]` header) comes back HTTP 200 with an EMPTY status.errors[] and the reason in the *per-document* result.status; result.content is "" (string) not [] (array), so the old Doc unmarshal silently failed and PutDoc reported success while the routine was never stored. PutDoc now decodes result.status and returns an error when it is non-empty. Regression guards added in internal/atelier/write_test.go (TestPutDocRejectedByStatus, TestStatMissing404). The gated TestSyncAxis_RealEngine fixture now writes a valid .mac with the ROUTINE header; the full push --from → diff → rm round-trip is GREEN against IRIS 2026.1 (and m-ydb's real tier stays green against YottaDB r2.07). go test -race / vet / gofmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/atelier/doc.go | 34 +++++++++++++++++++++++------ internal/atelier/write_test.go | 40 ++++++++++++++++++++++++++++++++++ sync_integration_test.go | 7 ++++-- 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/internal/atelier/doc.go b/internal/atelier/doc.go index 2122f52..d3d196f 100644 --- a/internal/atelier/doc.go +++ b/internal/atelier/doc.go @@ -72,12 +72,26 @@ func (c *Client) PutDoc(ctx context.Context, name string, content []string) (*Pu } res := &PutResult{Name: name} if len(env.Result) > 0 { - var doc Doc - if err := json.Unmarshal(env.Result, &doc); err == nil { - res.TS = doc.TS - if doc.Name != "" { - res.Name = doc.Name - } + // A save-time rejection (e.g. #16021 Illegal Header Line on a modern .mac + // without a `ROUTINE name [Type=MAC]` header) is reported HTTP 200 with the + // reason in the *per-document* result.status — NOT in status.errors[], so + // c.do does not catch it and an unguarded PUT would silently not store the + // routine. Decode the result fields we need directly (result.content is a + // "" on rejection vs a [] on success, so it cannot decode into Doc). + var pd struct { + Name string `json:"name"` + TS string `json:"ts"` + Status string `json:"status"` + } + if err := json.Unmarshal(env.Result, &pd); err != nil { + return nil, fmt.Errorf("atelier: decode PUT result for %q: %w", name, err) + } + if strings.TrimSpace(pd.Status) != "" { + return nil, fmt.Errorf("atelier: PUT %q rejected by the server: %s", name, pd.Status) + } + res.TS = pd.TS + if pd.Name != "" { + res.Name = pd.Name } } return res, nil @@ -122,11 +136,17 @@ func (c *Client) Stat(ctx context.Context, name string) (DocName, bool, error) { } // isNotFound reports whether an error is Atelier's "document does not exist" -// signal (the server returns it in status.errors, mapped to a Go error). +// signal. Modern IRIS (2026.1) answers GET /doc/{name} for a missing document +// with a bare HTTP 404; older servers embed "does not exist"/#5002 in +// status.errors. Recognize both so Stat/DeleteDoc treat an absent doc as +// not-found (exists=false) rather than a hard error. func isNotFound(err error) bool { if err == nil { return false } + if hasStatus(err, http.StatusNotFound) { + return true + } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "does not exist") || strings.Contains(msg, "#5002") || diff --git a/internal/atelier/write_test.go b/internal/atelier/write_test.go index 2b6788a..0025768 100644 --- a/internal/atelier/write_test.go +++ b/internal/atelier/write_test.go @@ -90,6 +90,46 @@ func TestPutDocServerError(t *testing.T) { } } +// TestPutDocRejectedByStatus is the regression guard for a real IRIS 2026.1 +// finding: a save-time rejection (e.g. #16021 Illegal Header Line on a modern +// .mac that lacks a `ROUTINE name [Type=MAC]` header) returns HTTP 200 with the +// reason in the *per-document* result.status, with an empty status.errors[] — +// so PutDoc must inspect result.status, not just the envelope, or it silently +// reports success while the routine is never stored. +func TestPutDocRejectedByStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + // HTTP 200, empty status.errors, but result.status carries the rejection + // and result.content is "" (a string, not the success [] array). + _, _ = io.WriteString(w, `{"status":{"errors":[],"summary":""},"result":{ + "name":"zzBAD.mac","ts":"","cat":"RTN","enc":false,"content":"", + "status":"ERROR #16021: Illegal Header Line: zzBAD ;x"}}`) + })) + defer srv.Close() + _, err := newTestClient(t, srv).PutDoc(context.Background(), "zzBAD.mac", []string{"zzBAD ;x", " q"}) + if err == nil || !strings.Contains(err.Error(), "#16021") { + t.Fatalf("expected the per-doc rejection surfaced, got %v", err) + } +} + +// TestStatMissing404 covers the other IRIS 2026.1 shape: a missing document is a +// bare HTTP 404 (older servers embed "does not exist"/#5002 in status.errors). +// Stat must read both as not-found (ok=false, no error). +func TestStatMissing404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"status":{"errors":[]},"result":{}}`) + })) + defer srv.Close() + _, ok, err := newTestClient(t, srv).Stat(context.Background(), "X.mac") + if err != nil { + t.Fatalf("Stat on a 404 should not error, got %v", err) + } + if ok { + t.Error("expected ok=false for a 404 (missing) doc") + } +} + func TestStat(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/sync_integration_test.go b/sync_integration_test.go index 7979fe8..7bfbcf7 100644 --- a/sync_integration_test.go +++ b/sync_integration_test.go @@ -65,8 +65,11 @@ func TestSyncAxis_RealEngine(t *testing.T) { } // push --from: create the scratch routine on the instance + mirror + manifest. + // Modern IRIS .mac UDL requires a `ROUTINE name [Type=MAC]` header line — a + // real-engine requirement the fake tier doesn't enforce (IRIS 2026.1). + header := "ROUTINE " + bare + " [Type=MAC]\n" from := t.TempDir() - if err := os.WriteFile(filepath.Join(from, docname), []byte(bare+" ;m-iris IT scratch\n quit\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(from, docname), []byte(header+bare+" ;m-iris IT scratch\n quit\n"), 0o644); err != nil { t.Fatal(err) } cc, buf := jsonCtx() @@ -87,7 +90,7 @@ func TestSyncAxis_RealEngine(t *testing.T) { } // diff: edit the mirror copy → the instance↔mirror diff surfaces the change. - if err := os.WriteFile(conn.Layout().RoutinePath(docname), []byte(bare+" ;m-iris IT scratch\n ; edited\n quit\n"), 0o644); err != nil { + if err := os.WriteFile(conn.Layout().RoutinePath(docname), []byte(header+bare+" ;m-iris IT scratch\n ; edited\n quit\n"), 0o644); err != nil { t.Fatal(err) } cc, buf = jsonCtx() From 5b06728138ed3ad8b87eb9a681442f517f4cf2ac Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Sat, 6 Jun 2026 08:32:19 -0400 Subject: [PATCH 10/24] coordination: repo CLAUDE.md + in-repo memory/tracker (driver-effort carve-outs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driver-spike rules for m-iris (lane = push m-iris only; SDK pinned, never edited here; honest caps). Memory moves in-repo to ./docs/memory/ (recall symlinked here); step-2 tracker is ./docs/m-iris-tracker.md — both EXCEPTIONS to the global/org rules to keep parallel iris/ydb spikes from clashing on the docs repo. See docs/m-engine-drivers/coordination-model.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 48 ++++++++++++++ docs/m-iris-tracker.md | 25 +++++++ docs/memory/MEMORY.md | 13 ++++ docs/memory/m-iris-driver-m0-spike.md | 94 +++++++++++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/m-iris-tracker.md create mode 100644 docs/memory/MEMORY.md create mode 100644 docs/memory/m-iris-driver-m0-spike.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43edbec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# m-iris — IRIS engine driver (D1). Repo rules. + +Adds to the org rules (`~/vista-cloud-dev/CLAUDE.md`) and the user global +(`~/.claude/CLAUDE.md`). Where this file says **EXCEPTION**, it *overrides* those +for this repo (the user authorized driver-effort carve-outs, 2026-06-06). + +This is a **driver spike** session — one of the three coordinated repos +(`m-driver-sdk` ⟷ `m-iris` ⟷ `m-ydb`). Read [[coordination-model]] +(`docs/m-engine-drivers/coordination-model.md` in the `docs` repo) once per fresh +session that touches the driver effort. + +## Lane — what this session owns +- **Owns / may push: `m-iris` only**, on branch **`m-iris-driver`** (never `main`). +- **Never edit `m-driver-sdk`** here, and never push `m-driver-sdk` / `m-ydb` / + `docs`. Those belong to the coordinator session. (Editing m-cli is out of scope + entirely until the D3 cutover.) + +## The SDK is pinned — do not touch it mid-spike +- Consume `github.com/vista-cloud-dev/m-driver-sdk` at the **pinned tagged version** + in `go.mod` (currently **v0.2.0**). No `replace` directives, no pseudo-versions. +- If you need a new shared shape (a type m-cli will read, or that m-ydb must match): + **do NOT bump the SDK from here.** Stub it locally, record `needs SDK: ` + in this repo's memory, and surface it for a coordinator session to batch into the + next SDK release. Re-pin only when the coordinator tags a new version. +- `caps` stays **honest** (advertise only wired verbs). The neutral contract + + envelope shapes are the m-cli surface; they change only via the SDK/contract, + which you don't edit here — so you cannot drift the surface. + +## Increment Protocol — EXCEPTIONS for this repo +Run the org Increment Protocol (persist memory → update tracker → commit+push) at +every verified increment, automatically, **but**: +- **EXCEPTION (memory):** m-iris memory lives in **`./docs/memory/`** (this repo), + committed here with the code. Do **NOT** write `~/claude/memory` and do **NOT** + write the `docs` repo's `docs/memory/` (that is shared coordination memory, + coordinator-owned). The harness recall path for an m-iris session is symlinked to + `./docs/memory/`. +- **EXCEPTION (tracker):** update **`./docs/m-iris-tracker.md`** (this repo), not the + shared `docs/m-engine-drivers/driver-implementation-plan.md` §5 — the coordinator + rolls the shared plan up at milestone boundaries. This keeps parallel iris/ydb + spikes from clashing on the `docs` repo. +- **Commit+push:** `m-iris` branch `m-iris-driver` only. Gates first: + `go test -race ./...`, `go vet`, `gofmt`, and `make test-it` against the live IRIS + (`m-test-iris`) for any Atelier-touching change. + +## Real-engine validation +Validate every milestone slice against real IRIS (`make test-it`, IRIS CE 2026.1, +`m-test-iris`) — the fake tier alone misses server-shape bugs (see this repo's +memory: the 404 / PutDoc-result.status findings). diff --git a/docs/m-iris-tracker.md b/docs/m-iris-tracker.md new file mode 100644 index 0000000..da80f91 --- /dev/null +++ b/docs/m-iris-tracker.md @@ -0,0 +1,25 @@ +# m-iris implementation tracker (D1) + +Per-repo tracker — the step-2 target for m-iris driver sessions (org Increment +Protocol). Update the active row here, in this repo, every increment. The shared +`docs/m-engine-drivers/driver-implementation-plan.md` §5 is the coordinator's +cross-repo roll-up, synced at milestone boundaries — do not edit it from a driver +spike. Status: ☐ todo · ◐ in progress · ☑ done. + +Pinned: `m-driver-sdk v0.2.0`. Branch: `m-iris-driver`. Transports: local·docker·remote. + +| M | Axis | Status | Notes | +|---|---|---|---| +| M0 | scaffold + SDK seam + `meta` | ☑ | honest caps golden; rename irissync→m-iris | +| M1 | lifecycle + health + doctor | ☑ | remote/attach; real-IRIS 2026.1 validated | +| M2 | sync (8 verbs) | ☑ | diff/rm/push --from/bare-name filter; real-IRIS green (404 + PutDoc bugs fixed) | +| M3 | exec (load/run/eval/abort) + engineError | ◐ | **next** — wire the remote runner Transport (already spiked) into exec; IRIS fault→§7; `--prefix`. Then build local/docker `iris session` transports (unblocks docker/local lifecycle up/down). | +| M4 | data (get/set/kill/query/export/import) | ☐ | remote via runner, SQL-wrapped | +| M5 | cover (%Monitor.LineByLine → LCOV) | ☐ | port mcov.FromMonitor | +| M6 | admin (backup/restore/check/journal) | ☐ | | +| M7 | native passthrough (iris/atelier/sql) | ☐ | | +| M8 | conformance green local+docker+remote | ☐ | release gate | + +**needs SDK:** (record here any shared shape M3+ requires that isn't in the pinned +SDK yet, for the coordinator to batch — none currently; M3 exec uses v0.2.0's +`Exec`/`EngineError`.) diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md new file mode 100644 index 0000000..6688f54 --- /dev/null +++ b/docs/memory/MEMORY.md @@ -0,0 +1,13 @@ +# Memory index — m-iris (IRIS engine driver, D1) + +Driver-local memory for the **m-iris** repo. A session started in +`~/vista-cloud-dev/m-iris` recalls from here (the harness memory path is symlinked +to this dir). Write m-iris-specific facts here — NOT to `~/claude/memory` and NOT +to the `docs` repo's `docs/memory/`. + +Cross-repo coordination (the consistency protocol, the SDK version ledger, the +driver contract, the frozen-SDK-window rhythm) lives in the **`docs` repo's +`docs/memory/`** + the org/per-repo `CLAUDE.md` — those load as rules; read them +for how m-iris stays in lockstep with m-ydb via `m-driver-sdk`. + +- [m-iris driver M0–M2 + remote spike](m-iris-driver-m0-spike.md) — IRIS driver (D1), branch `m-iris-driver`. M0+M1+M2 done — sync axis 8-verb parity (diff/rm/push --from/bare-name filter); real-IRIS-2026.1 validated (404 + PutDoc result.status bugs fixed, 8c2f010). Atelier-SQL runner substrate gated. Next M3 exec. Pins m-driver-sdk v0.2.0. diff --git a/docs/memory/m-iris-driver-m0-spike.md b/docs/memory/m-iris-driver-m0-spike.md new file mode 100644 index 0000000..d0f87d8 --- /dev/null +++ b/docs/memory/m-iris-driver-m0-spike.md @@ -0,0 +1,94 @@ +--- +name: m-iris-driver-m0-spike +description: "m-iris (IRIS driver D1) M0+M1+M2 done; Atelier-SQL runner substrate + lifecycle/health/doctor (remote) + sync axis complete (8 verbs). Next M3 exec via the remote runner." +metadata: + node_type: memory + type: project + originSessionId: b359cfba-9771-4992-b8ba-cefda6136bfe +--- + +m-iris (engine driver **D1**) lives at **`~/vista-cloud-dev/m-iris`** (dir renamed from +irissync 2026-06-04); module `github.com/vista-cloud-dev/m-iris`, binary `m-iris`, env +`M_IRIS_*`. GitHub repo renamed irissync→**`vista-cloud-dev/m-iris`** (public; old URL +auto-redirects). Branch `m-iris-driver` pushed (PR not yet opened to main). +Seeds from irissync's Atelier client. See repo `docs/m-iris-driver-status.md`. + +**M0 done (test-first, race-clean):** `internal/driver` vendors the contract thin — +`CapsDoc()` (honest caps golden, advertises only wired verbs), `ContractVersion`, +verb-level `Transport` (Exec/Load/ReadGlobal/SetGlobal/Health) + `FakeTransport`. +clikit exit ladder aligned to contract `0/2/3/4/5/6/7` (runtime moved 1→5; added +ExitUnreachable=6, ExitUnsupported=7) + `clikit.EngineError` envelope field (§7). +Command tree regrouped to `m-iris `: `meta` (caps/info/version/schema) ++ `sync` (the old list/pull/status/verify/push/deploy). + +**Remote spike done at unit tier (risk B2 — the whole remote substrate):** Atelier +has no run-ObjectScript endpoint, so all remote work rides `m_iris.Runner` +(`internal/remote/runner/m_iris.Runner.cls`) — role-gated parameterized SqlProc +methods, faults → `^mIrisRun(rid,"error")` in §7 shape. `atelier.Query` = +`POST {ns}/action/query`. `internal/remote.Transport` (implements driver.Transport) +lazily PUT+compiles the runner, then Exec/data/health over SQL. Fake-API unit tests +green every commit; `TestRemoteSpike_RealEngine` is **gated** (`M_IRIS_IT=1` + +`M_IRIS_*` env) and **not yet run green** — needs a provisioned IRIS CE container in +CI. The shared dev `vista-iris` container is OFF-LIMITS (docker exec denied). + +**M1 done (remote/attach, test-first):** `atelier.ServerInfo` (GET root → +version/api/namespaces) + typed `*HTTPError` (`IsUnauthorized`/`IsForbidden`, +401≠403). `--transport` flag (default remote; only remote wired). `lifecycle` axis: +status/`--probe`(exit 0/6)/`wait`(exit 6 timeout)/up/down/restart; provision+destroy +report unsupported exit 7 over Atelier (risk B4 — attach mode). `meta doctor`: typed +matrix (reachable/auth/version≥2022.1/namespace/query-privilege via action-query +SELECT 1/license-not-probed), exit 0/5/6. caps grew lifecycle+doctor (still honest). +Commits on branch `m-iris-driver`: 8d1a3a7 (M0+spike), 9180e1b (M1). + +**M2 DONE (2026-06-05) — committed+pushed `m-iris` b0531ed on `m-iris-driver`.** Plan §5 task 6 ☑. +Sync axis now 8-verb parity with m-ydb. Added: `sync diff [--from DIR]` (instance GET, +Stat-gated absent→pure add/del, vs mirror/--from; `{unified}` via new `internal/udiff` ported +byte-identical from m-ydb); `sync rm ` (DeleteDoc + mirror + manifest; `--dry-run`; +already-absent reported not error; `{removed}`); `push --from DIR` (pushes any dir incl. fresh +creates — staged into mirror so the conflict/lock/compile path is unchanged; up-to-date check +reads the --from copy so dry-run is accurate); **bare-name `--filter`** (glob on ext-stripped name +via new `match`/`bareName` in commands.go; `*.mac` no longer matches). caps advertises all 8 sync +verbs (golden regen); meta schema auto-includes diff/rm. SDK UNCHANGED (these are driver-local +shapes already in contract §5.2) — both drivers still pinned m-driver-sdk v0.2.0. Unit tier green +(-race/vet/gofmt). Gated `TestSyncAxis_RealEngine` (pkg main, `make test-it` now runs `. + remote`) +round-trips an ephemeral `zzMIRISIT` routine. + +**REAL-IRIS VALIDATED 2026-06-05 (commit 8c2f010): GREEN against live IRIS CE 2026.1** — the gated +sync round-trip caught **2 latent Atelier-client bugs the fake tier missed** (see +[[m-engine-drivers-real-engine-testing]] for the 2026.1 facts): (1) missing doc = HTTP 404 → +`isNotFound` now accepts 404 (Stat/DeleteDoc no longer hard-error on absent routines — affected +push conflict-check + diff + rm); (2) `.mac` PUT without a `ROUTINE name [Type=MAC]` header is +rejected #16021 as HTTP 200 with the reason in per-doc `result.status` (empty status.errors[]) → +`PutDoc` was silently "succeeding" without storing; now decodes result.status and errors. Unit +regression guards added (TestPutDocRejectedByStatus, TestStatMissing404). m-ydb real tier also +re-confirmed green vs YottaDB r2.07 same session. + +**Next:** M3 (wire the remote Transport into exec load/run/eval/abort + IRIS fault→§7 engineError + +`--prefix`; then build local/docker `iris session` transports — the deferred lifecycle up/down for +docker/local rides those). m-ydb is already at M3 done, so m-iris M3 closes the exec-parity gap. + +**Why:** the spike de-risks every remote feature at once; they all ride this one path. + +**REAL-IRIS VALIDATION (2026-06-04 — see [[m-engine-drivers-real-engine-testing]]):** first-ever run +against a real IRIS (disposable `m-test-iris`, intersystemsdc/iris-community **2026.1**, port 52774, +_SYSTEM/testsys). Remote spike + M1 status/doctor now GREEN. Fixed **4 latent bugs the fake tier +missed**: (1) runner class `m_iris.Runner` invalid — IRIS forbids underscores in class names +(#16006) → renamed `m.iris.Runner` (package m.iris → SQL schema m_iris, so m_iris.* SQL unchanged); +(2) Atelier error `code` is a NUMBER on 2026.1 (client typed string) → added `errCode`; (3) runner +ObjectScript spaces-after-commas → #1043 "QUIT argument not allowed" → removed comma-whitespace, +catch uses `do ..fault()`; (4) `ServerInfo` hit `/api/atelier/v1/` (404 on modern IRIS) → use +unversioned `/api/atelier/`. `make test-it` runs the gated tier. Committed a502071 on m-iris-driver. +NOTE: docs/m-iris-driver-status.md still says "ServerInfo GET /api/atelier/v1/" — now stale, fix later. + +**Phase-0 SDK freeze — DONE (2026-06-04, see [[m-driver-sdk-phase0]]):** the Transport was +frozen + extracted into `m-driver-sdk` (pkg `mdriver`) and m-iris switched onto it (commit +2d13d46 on `m-iris-driver`). Deleted `internal/driver/{transport,fake,transport_test}.go`; +caps.go `Caps` map→`mdriver` struct (honest set unchanged, golden regenerated); +`internal/remote` + meta retargeted to `mdriver`; `readEngineError`→`*mdriver.EngineError` +(behavior unchanged). go.mod `replace …/m-driver-sdk => ../m-driver-sdk`. Frozen verbs: +`Health·Load·Exec·ReadGlobal·SetGlobal` — m-ydb's `Compile`/`ExecMode`/flat `GlobalResult` +dropped; SetGlobal + GlobalNode tree + field-based Exec kept. `EngineError` now lives in the +SDK (clikit keeps its own for the envelope; convert at the boundary). All tests green; +`TestRemoteSpike_RealEngine` still gated. Spike assumptions still to confirm against a real +engine: SqlProc name `m_iris.`, `%Exception.Location` = `label+offset^routine`, +`@ref` indirection. Part of [[m-engine-drivers-project]]. From 5585d3beac2227d30d10a4756370a0f00e690159 Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Thu, 11 Jun 2026 18:37:30 -0400 Subject: [PATCH 11/24] irisdriver: public mdriver.Transport facade for m-cli/VistaEngine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add irisdriver.New(Config) (mdriver.Transport, error), composing atelier.New + remote.New so an external module (m-cli's VistaEngine) holds an IRIS Transport without importing internal/. type Config = atelier.Config; construction is lazy (runner deploys on first verb). Peer of m-ydb's ydbdriver facade — both drivers now present a public constructor returning the neutral contract. Live-validated vs m-test-iris (IRIS CE 2026.1, :52774) via a gated M_IRIS_IT=1 facade test: New -> Health -> Exec returns the real $ZV banner. Documents the cross-engine capture rule: IRIS Exec captures the result-global ^mIrisRun(rid,"out"), not device W output (the runner xecutes with no IO redirection), so the unified readiness/version probe is Health()+Version, not Exec("W $ZV"). YottaDB captures session stdout directly. Gates: go test -race ./..., go vet, gofmt green; facade IT green vs real IRIS. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/m-iris-tracker.md | 11 ++++- docs/memory/MEMORY.md | 1 + docs/memory/m-iris-public-facade.md | 34 +++++++++++++++ irisdriver/irisdriver.go | 36 ++++++++++++++++ irisdriver/irisdriver_it_test.go | 66 +++++++++++++++++++++++++++++ irisdriver/irisdriver_test.go | 26 ++++++++++++ 6 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 docs/memory/m-iris-public-facade.md create mode 100644 irisdriver/irisdriver.go create mode 100644 irisdriver/irisdriver_it_test.go create mode 100644 irisdriver/irisdriver_test.go diff --git a/docs/m-iris-tracker.md b/docs/m-iris-tracker.md index da80f91..94ab5ef 100644 --- a/docs/m-iris-tracker.md +++ b/docs/m-iris-tracker.md @@ -19,7 +19,14 @@ Pinned: `m-driver-sdk v0.2.0`. Branch: `m-iris-driver`. Transports: local·docke | M6 | admin (backup/restore/check/journal) | ☐ | | | M7 | native passthrough (iris/atelier/sql) | ☐ | | | M8 | conformance green local+docker+remote | ☐ | release gate | +| DRV | **public `irisdriver` facade** | ☑ | `New(Config)→(mdriver.Transport,error)` over Atelier REST + runner; the importable seam for m-cli/VistaEngine (vendor logic stays internal/). **Live-validated vs m-test-iris (2026.1):** New→Health→Exec($zv via result-global) returns the IRIS banner. | + +**Cross-engine note (for VistaEngine):** IRIS `Exec` captures the **result-global** +`^mIrisRun(rid,"out")`, NOT device `W` output — the runner `xecute`s with no IO +redirection, so a command must write its result into that global (remote.Exec +returns it as Stdout). YottaDB Exec captures session stdout directly. So the unified +"W $ZV" readiness/version probe is **`Health()` (+ Version)**, not `Exec("W $ZV")`. **needs SDK:** (record here any shared shape M3+ requires that isn't in the pinned -SDK yet, for the coordinator to batch — none currently; M3 exec uses v0.2.0's -`Exec`/`EngineError`.) +SDK yet, for the coordinator to batch — none currently; the facade + M3 exec use +v0.2.0's `Exec`/`EngineError`.) diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index 6688f54..037475a 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -11,3 +11,4 @@ driver contract, the frozen-SDK-window rhythm) lives in the **`docs` repo's for how m-iris stays in lockstep with m-ydb via `m-driver-sdk`. - [m-iris driver M0–M2 + remote spike](m-iris-driver-m0-spike.md) — IRIS driver (D1), branch `m-iris-driver`. M0+M1+M2 done — sync axis 8-verb parity (diff/rm/push --from/bare-name filter); real-IRIS-2026.1 validated (404 + PutDoc result.status bugs fixed, 8c2f010). Atelier-SQL runner substrate gated. Next M3 exec. Pins m-driver-sdk v0.2.0. +- [m-iris public facade](m-iris-public-facade.md) — NEW `irisdriver.New(Config)→mdriver.Transport` for m-cli/VistaEngine (peer of m-ydb's ydbdriver). Live-validated vs m-test-iris (banner returned). KEY RULE: IRIS Exec captures the result-global `^mIrisRun(rid,"out")`, NOT device `W` output → unified probe is `Health()`+Version, not `Exec("W $ZV")`. diff --git a/docs/memory/m-iris-public-facade.md b/docs/memory/m-iris-public-facade.md new file mode 100644 index 0000000..ee33824 --- /dev/null +++ b/docs/memory/m-iris-public-facade.md @@ -0,0 +1,34 @@ +--- +name: m-iris-public-facade +description: m-iris gained a public irisdriver.New facade returning mdriver.Transport for m-cli/VistaEngine; plus the IRIS Exec result-global capture rule. +metadata: + type: project +--- + +m-iris now exposes a public **`irisdriver`** package (`irisdriver/irisdriver.go`): +`New(Config) (mdriver.Transport, error)` with `type Config = atelier.Config`. It +composes `atelier.New` + `remote.New` so an external module (m-cli's +**VistaEngine**) can hold an IRIS `mdriver.Transport` without importing m-iris +`internal/`. Construction is lazy — no dial until the first verb (the runner class +is PUT+compiled on first use). This is the symmetric peer of m-ydb's +[[m-ydb-remote-ssh-transport]] `ydbdriver`: both drivers now have a public +constructor returning the neutral contract, so VistaEngine unifies IRIS (Atelier +REST :52773) and YottaDB (SSH / local / docker) behind one `Transport`. + +**Live-validated** against `m-test-iris` (IRIS CE 2026.1, host :52774, +_SYSTEM/testsys, ns USER) via a gated `M_IRIS_IT=1` facade test +(`irisdriver/irisdriver_it_test.go`): New → Health (privileged SELECT 1) → Exec +returned the real banner *"IRIS for UNIX … 2026.1 (Build 234U)"*. (HTTP path — +reachable here; docker-exec/ssh stay blocked.) + +**Cross-engine capture rule (important for VistaEngine).** The IRIS runner +`Eval` does `xecute cmd` with **no IO redirection**, so device `W` output is NOT +captured — `Exec("W $ZV")` yields empty Stdout. A command must write its result +into `^mIrisRun(rid,"out")` (e.g. `set ^mIrisRun("zzv","out")=$zv`), which +`remote.Exec` reads back as `ExecResult.Stdout`. YottaDB, by contrast, captures +the yottadb session's device stdout directly. **Consequence:** the unified +readiness/version probe across engines is `Transport.Health()` (carrying +`Version`), not `Exec("W $ZV")`. The runner has a `Ping()→$zversion` method and +IRIS also exposes version via the Atelier root (`ServerInfo`); wiring +`Health.Version` on both drivers is the clean unification (planned in the +VistaEngine increment). diff --git a/irisdriver/irisdriver.go b/irisdriver/irisdriver.go new file mode 100644 index 0000000..c5336d1 --- /dev/null +++ b/irisdriver/irisdriver.go @@ -0,0 +1,36 @@ +// Package irisdriver is m-iris's public, importable surface: it constructs an +// IRIS mdriver.Transport for in-process consumers (notably m-cli's VistaEngine) +// that speak only the neutral engine-driver contract. All vendor logic stays in +// internal/ — this package is the thin seam that lets another module hold an +// IRIS Transport without reaching into m-iris's internals. +// +// The transport is the `remote` substrate: every ObjectScript operation rides a +// role-gated runner class over the Atelier REST API (Atelier has no raw "run" +// endpoint), deployed on first use. This is how VistaEngine reaches a +// routines-embedded IRIS VistA over the network — the symmetric peer of the +// YottaDB SSH transport, both behind one mdriver.Transport. +package irisdriver + +import ( + mdriver "github.com/vista-cloud-dev/m-driver-sdk" + "github.com/vista-cloud-dev/m-iris/internal/atelier" + "github.com/vista-cloud-dev/m-iris/internal/remote" +) + +// Config is the IRIS Atelier connection. It re-exports the internal client +// config so external callers configure the engine without importing internal/: +// BaseURL (…/api/atelier/v1/), Namespace, and auth (Token | User+Password, +// optional CAFile / ClientCert / ClientKey for in-boundary or mutual TLS). +type Config = atelier.Config + +// New builds an IRIS Transport over the Atelier REST API. Construction does not +// dial the server; the runner class is PUT+compiled lazily on the first verb. +// Callers hold the result as an mdriver.Transport; the T0.1 readiness gate is +// Health (a privileged SELECT 1), and `W $ZV` is Exec of a command. +func New(cfg Config) (mdriver.Transport, error) { + client, err := atelier.New(cfg) + if err != nil { + return nil, err + } + return remote.New(client), nil +} diff --git a/irisdriver/irisdriver_it_test.go b/irisdriver/irisdriver_it_test.go new file mode 100644 index 0000000..5d51efa --- /dev/null +++ b/irisdriver/irisdriver_it_test.go @@ -0,0 +1,66 @@ +package irisdriver + +import ( + "context" + "os" + "strings" + "testing" + + mdriver "github.com/vista-cloud-dev/m-driver-sdk" +) + +// TestRealEngine_FacadeHealthAndZV exercises the public facade end-to-end against +// a live IRIS: New → Health (privileged SELECT 1) → Exec `W $ZV` (the T0.1 +// readiness gate). Gated by M_IRIS_IT=1 so unit CI skips it. Example: +// +// M_IRIS_IT=1 M_IRIS_BASE_URL=http://localhost:52774/api/atelier/v1/ \ +// M_IRIS_NAMESPACE=USER M_IRIS_USER=_SYSTEM M_IRIS_PASSWORD=testsys \ +// go test -run RealEngine ./irisdriver/ -v +func TestRealEngine_FacadeHealthAndZV(t *testing.T) { + if os.Getenv("M_IRIS_IT") != "1" { + t.Skip("set M_IRIS_IT=1 (+ M_IRIS_* connection env) to run the real-engine facade check") + } + env := func(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d + } + tr, err := New(Config{ + BaseURL: env("M_IRIS_BASE_URL", "http://localhost:52773/api/atelier/v1/"), + Namespace: env("M_IRIS_NAMESPACE", "USER"), + User: env("M_IRIS_USER", "_SYSTEM"), + Password: env("M_IRIS_PASSWORD", "SYS"), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + ctx := context.Background() + + h, err := tr.Health(ctx) + if err != nil { + t.Fatalf("Health: %v", err) + } + if !h.Healthy { + t.Fatalf("Health not healthy: %+v", h) + } + + // IRIS Exec captures the result-global, NOT device `W` output (the runner + // xecutes the command with no IO redirection), so the command writes $zv into + // ^mIrisRun(rid,"out"), which remote.Exec returns as Stdout. (On YottaDB the + // session's device stdout is captured directly — a real cross-engine + // difference VistaEngine handles via Health.Version rather than Exec("W $ZV").) + res, err := tr.Exec(ctx, mdriver.ExecRequest{ + Command: `set ^mIrisRun("zzv","out")=$zv`, Prefix: "zzv", + }) + if err != nil { + t.Fatalf("Exec($zv): %v", err) + } + if res.EngineError != nil { + t.Fatalf("engineError: %+v", res.EngineError) + } + if !strings.Contains(res.Stdout, "IRIS") && !strings.Contains(res.Stdout, "Cache") { + t.Fatalf("$ZV = %q, want an IRIS version banner", res.Stdout) + } + t.Logf("facade $ZV via result-global: %s", strings.TrimSpace(res.Stdout)) +} diff --git a/irisdriver/irisdriver_test.go b/irisdriver/irisdriver_test.go new file mode 100644 index 0000000..2eca58c --- /dev/null +++ b/irisdriver/irisdriver_test.go @@ -0,0 +1,26 @@ +package irisdriver + +import ( + "testing" + + mdriver "github.com/vista-cloud-dev/m-driver-sdk" +) + +// New must yield a value satisfying the neutral contract without dialing the +// server — this is the seam m-cli's VistaEngine holds. (Construction is lazy; +// the runner deploys on the first verb.) +func TestNew_SatisfiesTransport(t *testing.T) { + tr, err := New(Config{ + BaseURL: "https://iris.example:52773/api/atelier/v1/", + Namespace: "VISTA", + User: "_SYSTEM", + Password: "SYS", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + var _ mdriver.Transport = tr + if tr == nil { + t.Fatal("New returned a nil Transport") + } +} From 427f79785edf44b52251c5222c3222674780a26a Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Thu, 11 Jun 2026 19:13:42 -0400 Subject: [PATCH 12/24] meta: make `meta version` contract-conformant ({driver,engine,contract,build}) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m-driver-conformance flagged that `meta version` used the shared clikit.VersionCmd, emitting {version,commit,date,go} — which lacks the engine/contract that contract §5.7 requires (version = {driver, engine, contract, build}). m-ydb already had a driver-specific version; m-iris was the drift. Replace the field with a driver-specific versionCmd emitting {driver:"m-iris", engine:"iris", contract, build{version,commit,date,go}}. clikit stays engine-agnostic and byte-identical across drivers (engine/contract are driver facts, not clikit's). Conformance: 16/16 live vs m-test-iris (remote). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/m-iris-tracker.md | 3 ++- meta.go | 57 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/docs/m-iris-tracker.md b/docs/m-iris-tracker.md index 94ab5ef..f4c682d 100644 --- a/docs/m-iris-tracker.md +++ b/docs/m-iris-tracker.md @@ -19,7 +19,8 @@ Pinned: `m-driver-sdk v0.2.0`. Branch: `m-iris-driver`. Transports: local·docke | M6 | admin (backup/restore/check/journal) | ☐ | | | M7 | native passthrough (iris/atelier/sql) | ☐ | | | M8 | conformance green local+docker+remote | ☐ | release gate | -| DRV | **public `irisdriver` facade** | ☑ | `New(Config)→(mdriver.Transport,error)` over Atelier REST + runner; the importable seam for m-cli/VistaEngine (vendor logic stays internal/). **Live-validated vs m-test-iris (2026.1):** New→Health→Exec($zv via result-global) returns the IRIS banner. | +| DRV | **public `irisdriver` facade** | ☑ | `New(Config)→(mdriver.Transport,error)` over Atelier REST + runner; the importable seam for in-process embedders (vendor logic stays internal/). **Live-validated vs m-test-iris (2026.1):** New→Health→Exec($zv via result-global) returns the IRIS banner. | +| CFM | **`meta version` conformance fix** | ☑ | Was the shared `clikit.VersionCmd` (`{version,commit,date,go}`) — non-conformant: contract §5.7 version = `{driver,engine,contract,build}` (caught by `m-driver-conformance`). Replaced with a driver-specific `versionCmd` emitting `{driver:"m-iris",engine:"iris",contract,build{…}}`; clikit untouched (byte-identical). **Conformance now 16/16 live vs m-test-iris (remote).** | **Cross-engine note (for VistaEngine):** IRIS `Exec` captures the **result-global** `^mIrisRun(rid,"out")`, NOT device `W` output — the runner `xecute`s with no IO diff --git a/meta.go b/meta.go index 33f3433..0a7bb2e 100644 --- a/meta.go +++ b/meta.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "runtime" mdriver "github.com/vista-cloud-dev/m-driver-sdk" "github.com/vista-cloud-dev/m-iris/clikit" @@ -13,11 +14,57 @@ import ( // caps/version/info/schema are wired now; doctor (M1), selftest (M8), native + // shell (M7) join as their milestones land — and caps grows to advertise them. type metaCmd struct { - Caps capsCmd `cmd:"" help:"Emit the capability document (axes, transports, features) m-cli reads before calling optional verbs."` - Info infoCmd `cmd:"" help:"Driver identity + resolved engine target (edition/version filled by the M1 probe)."` - Doctor doctorCmd `cmd:"" help:"Typed preflight: reachable / auth / version / namespace / query-privilege / license (exit 0/5/6)."` - Version clikit.VersionCmd `cmd:"" help:"Show version and build info."` - Schema clikit.SchemaCmd `cmd:"" help:"Emit the command/flag tree as JSON (agent discovery)."` + Caps capsCmd `cmd:"" help:"Emit the capability document (axes, transports, features) m-cli reads before calling optional verbs."` + Info infoCmd `cmd:"" help:"Driver identity + resolved engine target (edition/version filled by the M1 probe)."` + Doctor doctorCmd `cmd:"" help:"Typed preflight: reachable / auth / version / namespace / query-privilege / license (exit 0/5/6)."` + Version versionCmd `cmd:"" help:"Show driver/engine/contract identity + build info (contract §5.7)."` + Schema clikit.SchemaCmd `cmd:"" help:"Emit the command/flag tree as JSON (agent discovery)."` +} + +// --- meta version ------------------------------------------------------------ + +// versionCmd emits the contract §5.7 version shape {driver, engine, contract, +// build}. engine + contract identify the driver to m-cli (it refuses an +// unknown major); build carries the link-time clikit build metadata. (The +// generic clikit build info alone is not contract-conformant — it lacks +// engine/contract — so meta version is driver-specific while clikit stays +// engine-agnostic and byte-identical across drivers.) +type versionCmd struct{} + +type versionBuild struct { + Version string `json:"version"` + Commit string `json:"commit"` + Date string `json:"date"` + Go string `json:"go"` +} + +type versionResult struct { + Driver string `json:"driver"` + Engine string `json:"engine"` + Contract string `json:"contract"` + Build versionBuild `json:"build"` +} + +func (versionCmd) Run(cc *clikit.Context) error { + res := versionResult{ + Driver: "m-iris", + Engine: "iris", + Contract: mdriver.ContractVersion, + Build: versionBuild{ + Version: clikit.Version, Commit: clikit.Commit, Date: clikit.Date, Go: runtime.Version(), + }, + } + return cc.Result(res, func() { + cc.Title("m-iris — version") + cc.KV( + [2]string{"driver", res.Driver}, + [2]string{"engine", res.Engine}, + [2]string{"contract", res.Contract}, + [2]string{"build", cc.Accent(res.Build.Version)}, + [2]string{"commit", res.Build.Commit}, + [2]string{"go", res.Build.Go}, + ) + }) } // --- meta caps --------------------------------------------------------------- From ee0d80ba8478d39a6c44e448bbc71f36d3f6a21b Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Thu, 11 Jun 2026 19:39:41 -0400 Subject: [PATCH 13/24] clikit: mirror ResultExit + fix doctor envelope/exit (byte-identical w/ m-ydb) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the shared clikit fix from m-ydb (kept byte-identical): add Context.ResultExit(data, exit, text) and have Run return cc.ExitCode(). meta doctor now uses ResultExit, so its unreachable path emits ok=false/exit=6 with process exit 6 — fixing the same latent cc.Result-then-Fail mismatch m-ydb had (driver-contract §2: envelope.exit == process exit). Conformance: 16/16 live vs m-test-iris (remote). Co-Authored-By: Claude Opus 4.8 (1M context) --- clikit/context.go | 24 +++++++++++++++ clikit/context_test.go | 67 ++++++++++++++++++++++++++++++++++++++++++ clikit/run.go | 4 ++- docs/m-iris-tracker.md | 1 + doctor.go | 16 +++------- 5 files changed, 99 insertions(+), 13 deletions(-) create mode 100644 clikit/context_test.go diff --git a/clikit/context.go b/clikit/context.go index 61a58cd..d856c45 100644 --- a/clikit/context.go +++ b/clikit/context.go @@ -60,6 +60,7 @@ type Context struct { th theme gl Glyph unicode bool + exit int // process exit recorded by ResultExit; Run returns it } // NewContext resolves the format/color for this invocation from the globals @@ -116,6 +117,29 @@ func (c *Context) Result(data any, text func()) error { return nil } +// ResultExit renders a command result that carries a deliberate exit code (and +// thus ok = exit==0): in JSON mode the data envelope with that exit/ok to +// stdout, otherwise the text closure. Unlike Fail — an error written to stderr +// with no data — this is for verbs whose payload IS the result even on a +// non-zero outcome (doctor: a failed check is still a full report; lint / +// roundtrip / status drift). The command returns the (nil) error from here and +// nothing else; Run reads ExitCode() for the process code, so the stdout +// envelope's exit always equals the process exit (driver-contract §2). +func (c *Context) ResultExit(data any, exit int, text func()) error { + c.exit = exit + if c.JSON() { + return c.emit(Envelope{SchemaVersion: SchemaVersion, Command: c.Command, OK: exit == ExitOK, Exit: exit, Data: data}) + } + if text != nil { + text() + } + return nil +} + +// ExitCode is the process exit a command recorded via ResultExit (ExitOK if it +// used the plain Result path). Run uses it when a command returns no error. +func (c *Context) ExitCode() int { return c.exit } + // Diagnostics renders a result that carries lint-style findings. func (c *Context) Diagnostics(data any, diags []Diagnostic, text func()) error { if c.JSON() { diff --git a/clikit/context_test.go b/clikit/context_test.go new file mode 100644 index 0000000..8512eec --- /dev/null +++ b/clikit/context_test.go @@ -0,0 +1,67 @@ +package clikit + +import ( + "bytes" + "encoding/json" + "testing" +) + +// ResultExit emits the data envelope with a deliberate exit code so the stdout +// envelope's exit matches the process exit (the driver-contract §2 invariant the +// conformance suite enforces). This is the "data + non-zero exit" path for verbs +// like doctor whose payload is still a full result on a non-zero outcome. + +func TestResultExit_JSONEnvelopeMatchesExit(t *testing.T) { + var buf bytes.Buffer + c := &Context{Stdout: &buf, Format: FormatJSON, Command: "meta doctor"} + type doc struct { + N int `json:"n"` + } + if err := c.ResultExit(doc{N: 3}, ExitUnreachable, nil); err != nil { + t.Fatalf("ResultExit: %v", err) + } + if c.ExitCode() != ExitUnreachable { + t.Errorf("ExitCode() = %d, want %d", c.ExitCode(), ExitUnreachable) + } + var env Envelope + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.OK { + t.Error("ok must be false for a non-zero exit") + } + if env.Exit != ExitUnreachable { + t.Errorf("envelope.exit = %d, want %d", env.Exit, ExitUnreachable) + } + if env.Data == nil { + t.Error("data must be present (the payload is the result)") + } +} + +func TestResultExit_ZeroExitIsOK(t *testing.T) { + var buf bytes.Buffer + c := &Context{Stdout: &buf, Format: FormatJSON, Command: "meta doctor"} + if err := c.ResultExit(struct{}{}, ExitOK, nil); err != nil { + t.Fatalf("ResultExit: %v", err) + } + if c.ExitCode() != ExitOK { + t.Errorf("ExitCode() = %d, want 0", c.ExitCode()) + } + var env Envelope + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !env.OK || env.Exit != ExitOK { + t.Errorf("env = {ok:%v exit:%d}, want ok+exit0", env.OK, env.Exit) + } +} + +// A plain Result leaves the exit at 0 (back-compat: Run returns ExitOK). +func TestResult_DefaultExitZero(t *testing.T) { + var buf bytes.Buffer + c := &Context{Stdout: &buf, Format: FormatJSON, Command: "x"} + _ = c.Result(struct{}{}, nil) + if c.ExitCode() != ExitOK { + t.Errorf("ExitCode() = %d, want 0", c.ExitCode()) + } +} diff --git a/clikit/run.go b/clikit/run.go index 3938a8f..e551669 100644 --- a/clikit/run.go +++ b/clikit/run.go @@ -49,5 +49,7 @@ func Run(name, description string, cli any, g *Globals, extra ...kong.Option) in RenderError(cc, err) return exitOf(err) } - return ExitOK + // A command may have recorded a deliberate non-zero exit via ResultExit + // (its data envelope is already on stdout); otherwise this is ExitOK. + return cc.ExitCode() } diff --git a/docs/m-iris-tracker.md b/docs/m-iris-tracker.md index f4c682d..8592305 100644 --- a/docs/m-iris-tracker.md +++ b/docs/m-iris-tracker.md @@ -21,6 +21,7 @@ Pinned: `m-driver-sdk v0.2.0`. Branch: `m-iris-driver`. Transports: local·docke | M8 | conformance green local+docker+remote | ☐ | release gate | | DRV | **public `irisdriver` facade** | ☑ | `New(Config)→(mdriver.Transport,error)` over Atelier REST + runner; the importable seam for in-process embedders (vendor logic stays internal/). **Live-validated vs m-test-iris (2026.1):** New→Health→Exec($zv via result-global) returns the IRIS banner. | | CFM | **`meta version` conformance fix** | ☑ | Was the shared `clikit.VersionCmd` (`{version,commit,date,go}`) — non-conformant: contract §5.7 version = `{driver,engine,contract,build}` (caught by `m-driver-conformance`). Replaced with a driver-specific `versionCmd` emitting `{driver:"m-iris",engine:"iris",contract,build{…}}`; clikit untouched (byte-identical). **Conformance now 16/16 live vs m-test-iris (remote).** | +| CFM2 | **clikit `ResultExit` + doctor envelope/exit** | ☑ | Mirrored the shared clikit fix (byte-identical with m-ydb): `Context.ResultExit(data, exit, text)` so `meta doctor` emits its data envelope with the resolved exit (0/5/6) and `Run` returns `cc.ExitCode()`. doctor's unreachable path now emits `ok=false, exit=6` with process exit 6 (was the latent `cc.Result`-then-`Fail` stdout-exit-0 mismatch). Conformance stays 16/16 live. | **Cross-engine note (for VistaEngine):** IRIS `Exec` captures the **result-global** `^mIrisRun(rid,"out")`, NOT device `W` output — the runner `xecute`s with no IO diff --git a/doctor.go b/doctor.go index c603b46..b56f5c2 100644 --- a/doctor.go +++ b/doctor.go @@ -34,18 +34,10 @@ func (doctorCmd) Run(cc *clikit.Context, conn *config.Conn) error { return err } res, exit := runDoctorRemote(context.Background(), conn) - if err := cc.Result(res, func() { renderDoctor(cc, res) }); err != nil { - return err - } - switch exit { - case clikit.ExitUnreachable: - return clikit.Fail(clikit.ExitUnreachable, "UNREACHABLE", - "engine unreachable — fix connectivity before other checks", "verify --base-url / network") - case clikit.ExitRuntime: - return clikit.Fail(clikit.ExitRuntime, "PREFLIGHT_FAILED", - "one or more preflight checks failed", "see the failing checks above") - } - return nil + // doctor's payload is a full report even on a non-zero outcome, so emit the + // data envelope with the resolved exit (0 / 5 / 6) — the envelope's exit then + // matches the process exit (driver-contract §2). + return cc.ResultExit(res, exit, func() { renderDoctor(cc, res) }) } // runDoctorRemote runs the remote (Atelier) check matrix and returns the typed From 8b8ed7657e2117df4708b1895146e5b6e3d81cba Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Thu, 11 Jun 2026 19:41:32 -0400 Subject: [PATCH 14/24] test: update doctor tests for the ResultExit pattern doctor now emits its data envelope via cc.ResultExit and returns nil (the exit is recorded on the Context, not a returned *clikit.Error). Update the three doctor exit tests to assert cc.ExitCode() instead of the returned error. Fixes the red gate the previous commit introduced. go test -race ./... green. Co-Authored-By: Claude Opus 4.8 (1M context) --- doctor_test.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/doctor_test.go b/doctor_test.go index 5b37e36..0acf78e 100644 --- a/doctor_test.go +++ b/doctor_test.go @@ -81,8 +81,10 @@ func TestDoctor_AuthFailExit5(t *testing.T) { srv := doctorServer(http.StatusUnauthorized, nil) defer srv.Close() cc, buf := jsonCtx() - err := (doctorCmd{}).Run(cc, doctorConn(srv.URL, "VISTA")) - if code := exitOf(t, err); code != clikit.ExitRuntime { + if err := (doctorCmd{}).Run(cc, doctorConn(srv.URL, "VISTA")); err != nil { + t.Fatalf("doctor should not return an error (the data envelope carries the outcome): %v", err) + } + if code := cc.ExitCode(); code != clikit.ExitRuntime { t.Fatalf("auth-fail doctor exit = %d, want %d", code, clikit.ExitRuntime) } d := decodeDoctor(t, buf.Bytes()) @@ -99,8 +101,10 @@ func TestDoctor_UnreachableExit6(t *testing.T) { srv := doctorServer(0, nil) srv.Close() // refuse connections cc, _ := jsonCtx() - err := (doctorCmd{}).Run(cc, doctorConn(srv.URL, "VISTA")) - if code := exitOf(t, err); code != clikit.ExitUnreachable { + if err := (doctorCmd{}).Run(cc, doctorConn(srv.URL, "VISTA")); err != nil { + t.Fatalf("doctor should not return an error: %v", err) + } + if code := cc.ExitCode(); code != clikit.ExitUnreachable { t.Fatalf("unreachable doctor exit = %d, want %d", code, clikit.ExitUnreachable) } } @@ -111,8 +115,10 @@ func TestDoctor_NamespaceMissingExit5(t *testing.T) { srv := doctorServer(0, []string{"%SYS", "USER"}) defer srv.Close() cc, buf := jsonCtx() - err := (doctorCmd{}).Run(cc, doctorConn(srv.URL, "VISTA")) - if code := exitOf(t, err); code != clikit.ExitRuntime { + if err := (doctorCmd{}).Run(cc, doctorConn(srv.URL, "VISTA")); err != nil { + t.Fatalf("doctor should not return an error: %v", err) + } + if code := cc.ExitCode(); code != clikit.ExitRuntime { t.Fatalf("missing-namespace doctor exit = %d, want %d", code, clikit.ExitRuntime) } d := decodeDoctor(t, buf.Bytes()) From 33019b72f1bb1af088de5cb8b6fc83cb211b592f Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Fri, 12 Jun 2026 13:38:43 -0400 Subject: [PATCH 15/24] feat(exec): wire exec axis over the remote runner; close VSL M0a T0a.5 IRIS driver-path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the neutral `exec load/run/eval` axis to m-iris (the gap that made `v pkg install --engine iris` silently no-op — the SDK reference Client shells `m-iris exec …`, which previously returned USAGE/exit 2). All additive in m-iris; SDK stays pinned v0.2.0. - exec.go + execCmd mounted as `Exec` in CLI; caps advertises exec (golden regen); load → remote.Transport.Load, run/eval → remote.Transport.Exec; IRIS fault → §7. - Load: map neutral `.m` → `.int` and prepend the UDL `ROUTINE [Type=INT]` header Atelier requires (else #16021 Illegal Header Line) — caught only live. - Device-`W` capture: runner RunRef/Eval bracket execution with start^mIrisIO/ stop^mIrisIO (%Device.ReDirectIO + a companion mIrisIO.int whose wstr/wchr/wnl labels append to ^mIrisRun(rid,"out") — a class method can't host mnemonic-space labels). stop() never throws and restores the original mnemonic. - KIDS-over-Atelier recovery: EN^XPDIJ reconfigures the SQL-gateway device, losing the action/query response body (HTTP 200 + empty) though the run completes. So RunRef/Eval record status/out/error in ^mIrisRun(rid,*) and set "done" last; Exec recovers the outcome from those globals — Base64 via GetOut (control bytes survive), retrying on fresh connections (CloseIdleConnections) until a clean gateway process serves the read. Tests: exec_test.go (command tier), TestLoad_MapsDotMToIntDocname + #16021 fake guard, TestRemoteExecAxis_RealEngine (load→run→read-stdout). Gates green: race/gofmt/vet/lint, make test-it vs foia (sync/spike/exec RealEngine), SDK conformance 16/0. T0a.5 driver-path PROVEN on foia: v pkg install/verify/uninstall --engine iris, all 3 M0a invariants, deterministic. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/m-iris-tracker.md | 24 +++- docs/memory/MEMORY.md | 3 +- docs/memory/m-iris-exec-axis-t0a5.md | 92 ++++++++++++ exec.go | 147 +++++++++++++++++++ exec_test.go | 150 ++++++++++++++++++++ internal/atelier/client.go | 7 + internal/driver/caps.go | 4 + internal/driver/testdata/caps.golden.json | 5 + internal/remote/integration_test.go | 65 +++++++++ internal/remote/remote.go | 163 +++++++++++++++++++--- internal/remote/remote_test.go | 88 +++++++++++- internal/remote/runner/m.iris.Runner.cls | 24 ++++ internal/remote/runner/mIrisIO.int | 82 +++++++++++ main.go | 1 + 14 files changed, 822 insertions(+), 33 deletions(-) create mode 100644 docs/memory/m-iris-exec-axis-t0a5.md create mode 100644 exec.go create mode 100644 exec_test.go create mode 100644 internal/remote/runner/mIrisIO.int diff --git a/docs/m-iris-tracker.md b/docs/m-iris-tracker.md index 8592305..a03a759 100644 --- a/docs/m-iris-tracker.md +++ b/docs/m-iris-tracker.md @@ -13,7 +13,7 @@ Pinned: `m-driver-sdk v0.2.0`. Branch: `m-iris-driver`. Transports: local·docke | M0 | scaffold + SDK seam + `meta` | ☑ | honest caps golden; rename irissync→m-iris | | M1 | lifecycle + health + doctor | ☑ | remote/attach; real-IRIS 2026.1 validated | | M2 | sync (8 verbs) | ☑ | diff/rm/push --from/bare-name filter; real-IRIS green (404 + PutDoc bugs fixed) | -| M3 | exec (load/run/eval/abort) + engineError | ◐ | **next** — wire the remote runner Transport (already spiked) into exec; IRIS fault→§7; `--prefix`. Then build local/docker `iris session` transports (unblocks docker/local lifecycle up/down). | +| M3 | exec (load/run/eval/abort) + engineError | ◐ | **exec `load`/`run`/`eval` WIRED over the remote runner (2026-06-12)** — `exec.go` + `execCmd` mounted in `CLI`; caps advertises `exec`; IRIS fault→§7 engineError; the SDK reference `Client` now drives a live VistA over the seam. Device `W` output is now CAPTURED (see device-capture note below). **T0a.5 driver-path PROVEN on foia** (`v pkg install/verify/uninstall --engine iris` — all 3 M0a invariants green, deterministic). `--prefix` on run; `abort` + local/docker `iris session` transports still ☐. | | M4 | data (get/set/kill/query/export/import) | ☐ | remote via runner, SQL-wrapped | | M5 | cover (%Monitor.LineByLine → LCOV) | ☐ | port mcov.FromMonitor | | M6 | admin (backup/restore/check/journal) | ☐ | | @@ -23,11 +23,23 @@ Pinned: `m-driver-sdk v0.2.0`. Branch: `m-iris-driver`. Transports: local·docke | CFM | **`meta version` conformance fix** | ☑ | Was the shared `clikit.VersionCmd` (`{version,commit,date,go}`) — non-conformant: contract §5.7 version = `{driver,engine,contract,build}` (caught by `m-driver-conformance`). Replaced with a driver-specific `versionCmd` emitting `{driver:"m-iris",engine:"iris",contract,build{…}}`; clikit untouched (byte-identical). **Conformance now 16/16 live vs m-test-iris (remote).** | | CFM2 | **clikit `ResultExit` + doctor envelope/exit** | ☑ | Mirrored the shared clikit fix (byte-identical with m-ydb): `Context.ResultExit(data, exit, text)` so `meta doctor` emits its data envelope with the resolved exit (0/5/6) and `Run` returns `cc.ExitCode()`. doctor's unreachable path now emits `ok=false, exit=6` with process exit 6 (was the latent `cc.Result`-then-`Fail` stdout-exit-0 mismatch). Conformance stays 16/16 live. | -**Cross-engine note (for VistaEngine):** IRIS `Exec` captures the **result-global** -`^mIrisRun(rid,"out")`, NOT device `W` output — the runner `xecute`s with no IO -redirection, so a command must write its result into that global (remote.Exec -returns it as Stdout). YottaDB Exec captures session stdout directly. So the unified -"W $ZV" readiness/version probe is **`Health()` (+ Version)**, not `Exec("W $ZV")`. +**Device-capture note (UPDATED 2026-06-12 — supersedes the old "no IO redirection" +note):** IRIS `Exec` now CAPTURES device `W` output. The runner's `RunRef`/`Eval` +bracket `do @ref`/`xecute` with `start^mIrisIO`/`stop^mIrisIO`, which turn on +`##class(%Device).ReDirectIO` and point the principal device's mnemonic space at +the companion `mIrisIO.int` routine; its `wstr`/`wchr`/`wnl`/`wff`/`wtab` labels +append every WRITE to `^mIrisRun(rid,"out")`, which `remote.Exec` returns as +`Stdout`. (A class method can't host mnemonic-space labels — hence the separate +`.int` routine, deployed + compiled alongside the class by `ensureRunner`.) This is +what lets v-pkg's `<>key=val` install markers flow back. **KIDS-install +caveat:** `EN^XPDIJ` reconfigures the Atelier SQL-gateway device with USE-params +ReDirectIO can't intercept, so the action/query RESPONSE BODY is lost (HTTP 200 + +empty body) even though the run completes; the runner therefore records +`status`/`out`/`error` in `^mIrisRun(rid,*)` and sets `"done"` LAST, and `Exec` +RECOVERS the outcome from those globals — Base64-encoded (`GetOut`) so control bytes +survive, retrying on fresh connections (`CloseIdleConnections`) until a clean +gateway process serves the read. `Health()`+Version remains the portable readiness +probe; `W $ZV` via `Exec` now also works on IRIS. **needs SDK:** (record here any shared shape M3+ requires that isn't in the pinned SDK yet, for the coordinator to batch — none currently; the facade + M3 exec use diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index 037475a..d67c198 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -11,4 +11,5 @@ driver contract, the frozen-SDK-window rhythm) lives in the **`docs` repo's for how m-iris stays in lockstep with m-ydb via `m-driver-sdk`. - [m-iris driver M0–M2 + remote spike](m-iris-driver-m0-spike.md) — IRIS driver (D1), branch `m-iris-driver`. M0+M1+M2 done — sync axis 8-verb parity (diff/rm/push --from/bare-name filter); real-IRIS-2026.1 validated (404 + PutDoc result.status bugs fixed, 8c2f010). Atelier-SQL runner substrate gated. Next M3 exec. Pins m-driver-sdk v0.2.0. -- [m-iris public facade](m-iris-public-facade.md) — NEW `irisdriver.New(Config)→mdriver.Transport` for m-cli/VistaEngine (peer of m-ydb's ydbdriver). Live-validated vs m-test-iris (banner returned). KEY RULE: IRIS Exec captures the result-global `^mIrisRun(rid,"out")`, NOT device `W` output → unified probe is `Health()`+Version, not `Exec("W $ZV")`. +- [m-iris public facade](m-iris-public-facade.md) — NEW `irisdriver.New(Config)→mdriver.Transport` for m-cli/VistaEngine (peer of m-ydb's ydbdriver). Live-validated vs m-test-iris (banner returned). NOTE: the old "IRIS Exec does NOT capture device `W`" rule is **superseded** by [[m-iris-exec-axis-t0a5]] — the runner now redirects device output into `^mIrisRun(rid,"out")`. +- [exec axis + T0a.5 driver-path](m-iris-exec-axis-t0a5.md) — **M0a T0a.5 PROVEN on IRIS foia (2026-06-12)**: wired `exec load/run/eval` over the remote runner (closes the no-op gap), `.m`→`.int` + UDL `ROUTINE … [Type=INT]` header (#16021), device-`W` capture via `%Device.ReDirectIO`+companion `mIrisIO.int`, and the KIDS-over-Atelier device-corruption recovery (200+empty-body → `done`-gated global recovery, Base64 `GetOut`, retry on fresh connection). `v pkg install/verify/uninstall --engine iris` green. SDK still v0.2.0. diff --git a/docs/memory/m-iris-exec-axis-t0a5.md b/docs/memory/m-iris-exec-axis-t0a5.md new file mode 100644 index 0000000..7986b68 --- /dev/null +++ b/docs/memory/m-iris-exec-axis-t0a5.md @@ -0,0 +1,92 @@ +--- +name: m-iris-exec-axis-t0a5 +description: m-iris exec axis (load/run/eval) wired over the remote runner + the device-output capture machinery that closed VSL M0a's T0a.5 IRIS driver-path on foia. Has the hard-won KIDS-over-Atelier device-corruption findings. +metadata: + node_type: memory + type: project +--- + +**M0a T0a.5 driver-path PROVEN on IRIS FOIA (foia) 2026-06-12.** `v pkg +install/verify/uninstall --engine iris --transport remote` runs the full +KIDS lifecycle over the m-iris driver — all 3 invariants green, deterministic +across repeated runs: #9.7 status piece-9 = 3, `$$PING^ZZSKEL()`→"pong" (routine +loaded; verify `routines:{ZZSKEL:true}`), reversible uninstall (post-uninstall +verify = not installed). This + the already-green YDB driver-path = **M0a done**. +Branch `m-iris-driver`; SDK still pinned **v0.2.0** (everything additive in m-iris, +no SDK change, no frozen-SDK window). + +## The exec axis (closes the gap that made install silently no-op) +Root cause of the original no-op: m-iris did NOT implement the neutral `exec` axis +the SDK reference `Client` shells to (`m-iris exec load/run` → `USAGE: unexpected +argument exec`, exit 2, which the Client swallowed). Fixed, all in m-iris: +- **`exec.go`** — `execCmd{Load,Run,Eval}` mounted as `Exec` in the `CLI` struct + (main.go), mirroring m-ydb. `load` → `remote.Transport.Load`; `run`/`eval` → + `remote.Transport.Exec`. `caps.go` advertises `Exec:["load","run","eval"]` + (abort NOT wired). Caps golden regenerated (`UPDATE_GOLDEN=1`). +- **`.m`→`.int` docname** (`irisDocname`) — the neutral `.m` extension the SDK/v-pkg + stage is not an Atelier routine type; map to `.int` (classic MUMPS, matches the + routine-wrap label+space-code body). +- **UDL routine header REQUIRED** (`irisRoutineLines`) — Atelier rejects a routine + PUT whose first line is a label, with `ERROR #16021: Illegal Header Line`. Prepend + `ROUTINE [Type=INT|MAC|INC]` (idempotent; skips a doc that already leads + with `ROUTINE `, and `.cls`). **The fake tier missed this — only the live engine + caught it** (encoded back into the fake's `PutDoc` as a #16021 guard). + +## Device-output capture (the deepest part — 4 layered findings) +Atelier/SQL has no principal device a script's `W` output can be read from. The +runner now captures it. Each layer below was a separate live-only failure: + +1. **Capture via `%Device.ReDirectIO` + a companion `.int` routine.** `RunRef`/`Eval` + bracket `do @ref`/`xecute` with `start^mIrisIO`/`stop^mIrisIO`. start() turns on + `##class(%Device).ReDirectIO(1)` and `use $io::("^mIrisIO")` — every WRITE then + dispatches to mIrisIO's `wstr`/`wchr`/`wnl`/`wff`/`wtab` labels, which append to + `^mIrisRun(rid,"out")`. **A class method CANNOT host mnemonic-space labels**, so + mIrisIO is a separate `.int` routine, deployed + compiled with the class by + `ensureRunner`. stop() must NEVER throw (try/catch) and restores the device's + ORIGINAL mnemonic (saved via `GetMnemonicRoutine`) — else the framework's later + writes dispatch into mIrisIO. + +2. **KIDS `EN^XPDIJ` corrupts the Atelier SQL-gateway device.** It issues + `USE IO:(params)` (terminal mode) that ReDirectIO does NOT intercept, so the + action/query **RESPONSE BODY is lost: HTTP 200 with an empty body** (proven with + an in-IRIS `%Net.HttpRequest` probe). The Go atelier client then errors + ("HTTP 500"-ish). **The run still COMPLETES** — globals are set, #9.7 reaches 3. + `write *-3` / mnemonic-restore did NOT fix the lost body. + +3. **Recover the outcome from globals, not the response.** Runner `RunRef`/`Eval` + record `status`/`out`/`error` in `^mIrisRun(rid,*)` and set **`"done"` LAST**. + `Transport.Exec` ignores the (possibly-lost) response and reads the outcome from + the globals, gating on `"done"` (missing → the run truly didn't run). + +4. **Binary-safe + fresh-connection reads.** The captured `out` has control bytes + (ANSI/ESC/CR from KIDS) that mangle/truncate over action/query JSON — so a new + runner method **`GetOut(rid)` Base64-encodes** it (`$system.Encryption.Base64Encode`; + Go strips whitespace before decoding). AND the corrupted gateway process keeps + spoiling responses on the same keep-alive connection — so `recoverRun` calls + **`CloseIdleConnections()` and RETRIES** (up to ~2s) so a fresh connection lands + on a clean process. Both were necessary; either alone still failed. + +**JOB-isolation was tried and rejected:** running the install in a JOB'd child +keeps the SqlProc's device clean (no lost body) BUT the child's redirect drops at +`XPDIJ`'s end (its principal differs), truncating capture before the marker. Inline +capture is complete; the global-recovery path handles the lost response. + +## Gotcha — corrupt half-installs poison the next install (cost real time) +Aborted/500'd install runs leave `#9.7 "B"` xref entries (written by +`$$INST^XPDIL1` before `EN^XPDIJ` completes). The install script's +`I $D(^XPD(9.7,"B",name))` guard then fires "already-installed", and uninstall's +`$O`+`DIK` removes only ONE entry. Symptom: install reports `status=0` / +already-installed on a "clean" system. **Purge by IEN** before a clean run: +`F S da=$O(^XPD(9.7,"B",name,"")) Q:da="" K ^XPD(9.7,da),^XPD(9.7,"B",name,da),^XPD(9.7,"ASP",da),^XTMP("XPDI",da)` +(+ #9.6 B). See the cleanup routine pattern in [[t0a3-live-install-handoff]]. + +## Gates (all green 2026-06-12) +`go test -race ./...`, gofmt, vet, golangci-lint (no new findings) ✅; `make test-it` +vs foia (TestSyncAxis / TestRemoteSpike / **TestRemoteExecAxis** RealEngine) ✅; SDK +conformance 16/0 vs the rebuilt m-iris (remote) ✅. New: `exec_test.go` (command tier), +`TestLoad_MapsDotMToIntDocname` + header guard, `TestRemoteExecAxis_RealEngine` +(load→run→read-stdout). See [[m-iris-driver-m0-spike]], [[m-iris-public-facade]]. + +**Flipping the shared VSL `T0a.5` row to ☑ in m-stdlib's +`docs/tracking/vsl-implementation-tracker.md` is a coordinator/v-pkg-session +action** (not this driver lane). diff --git a/exec.go b/exec.go new file mode 100644 index 0000000..9ae46b5 --- /dev/null +++ b/exec.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "fmt" + "strings" + + mdriver "github.com/vista-cloud-dev/m-driver-sdk" + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/config" + "github.com/vista-cloud-dev/m-iris/internal/remote" +) + +// execCmd is the exec axis (driver-contract §5.3) over the IRIS `remote` +// transport: run M against the attached namespace through the m.iris.Runner +// substrate. load PUT+compiles routine source over Atelier (neutral .m source is +// staged as a classic .int routine); run executes an entryref and eval one +// command, each through the runner's fault trap that surfaces a structured +// engineError (§7) on a runtime fault. All three ride internal/remote.Transport +// — the substrate the remote spike de-risked; this axis wires it to the CLI so +// the SDK reference Client (and therefore `v pkg install`) can drive a lifecycle. +type execCmd struct { + Load execLoadCmd `cmd:"" name:"load" help:"Stage routine source into the namespace (Atelier PUT) and compile it; neutral .m → .int. Compile faults surface as engineError."` + Run execRunCmd `cmd:"" name:"run" help:"Run an entryref (LABEL^ROUTINE) through the runner; args → the formallist. Faults surface as engineError."` + Eval execEvalCmd `cmd:"" name:"eval" help:"Evaluate a single M command through the runner. Faults surface as engineError."` +} + +type execResult struct { + Stdout string `json:"stdout"` + Status int `json:"status"` +} + +type execLoadResult struct { + Loaded []string `json:"loaded"` + Compiled bool `json:"compiled"` +} + +// remoteTransport builds the remote (Atelier REST + runner) transport for the +// exec axis, after refusing the not-yet-wired local/docker transports. +func remoteTransport(conn *config.Conn) (*remote.Transport, error) { + if err := remoteOnly(conn); err != nil { + return nil, err + } + client, err := remoteClient(conn) + if err != nil { + return nil, err + } + return remote.New(client), nil +} + +// --- load -------------------------------------------------------------------- + +type execLoadCmd struct { + Paths []string `arg:"" optional:"" help:"Routine source files (or directories) to stage."` + Prefix string `help:"Ephemeral docname prefix applied to each staged routine." placeholder:"PREFIX"` +} + +func (c *execLoadCmd) Run(cc *clikit.Context, conn *config.Conn) error { + if len(c.Paths) == 0 { + return clikit.Fail(clikit.ExitUsage, "NO_SOURCE", "exec load needs ", "") + } + tr, err := remoteTransport(conn) + if err != nil { + return err + } + res, err := tr.Load(context.Background(), mdriver.LoadRequest{Paths: c.Paths, Prefix: c.Prefix}) + if err != nil { + return runtimeErr(err) + } + if res.EngineError != nil { + msg := strings.TrimSpace(res.EngineError.Mnemonic + " " + res.EngineError.Text) + return clikit.FailEngine(clikit.ExitRuntime, "COMPILE_ERROR", "compile failed: "+msg, "", toClikitEngineError(res.EngineError)) + } + return cc.Result(execLoadResult{Loaded: nonNil(res.Loaded), Compiled: true}, func() { + cc.Title("load complete") + cc.KV([2]string{"loaded", fmt.Sprint(len(res.Loaded))}, [2]string{"compiled", "yes"}) + fmt.Fprintln(cc.Stdout, cc.Success("routines staged + compiled")) + }) +} + +// --- run --------------------------------------------------------------------- + +type execRunCmd struct { + EntryRef string `arg:"" help:"Entryref to run (LABEL^ROUTINE or ^ROUTINE)."` + Args []string `arg:"" optional:"" help:"Arguments passed to the entryref."` + Prefix string `help:"Ephemeral-run prefix; the runner keys its result global by it." placeholder:"PREFIX"` +} + +func (c *execRunCmd) Run(cc *clikit.Context, conn *config.Conn) error { + return runExec(cc, conn, mdriver.ExecRequest{EntryRef: c.EntryRef, Args: c.Args, Prefix: c.Prefix}) +} + +// --- eval -------------------------------------------------------------------- + +type execEvalCmd struct { + Command []string `arg:"" help:"M command to evaluate (joined with spaces; quote it as one shell arg)."` +} + +func (c *execEvalCmd) Run(cc *clikit.Context, conn *config.Conn) error { + return runExec(cc, conn, mdriver.ExecRequest{Command: strings.Join(c.Command, " ")}) +} + +// --- shared ------------------------------------------------------------------ + +// runExec dispatches req through the remote runner and renders the result: a §7 +// fault becomes an ok=false envelope with engineError (exit 5); otherwise +// {stdout, status}. +func runExec(cc *clikit.Context, conn *config.Conn, req mdriver.ExecRequest) error { + tr, err := remoteTransport(conn) + if err != nil { + return err + } + res, err := tr.Exec(context.Background(), req) + if err != nil { + return runtimeErr(err) + } + if res.EngineError != nil { + msg := res.EngineError.Mnemonic + if res.EngineError.Text != "" { + msg = strings.TrimSpace(msg + " " + res.EngineError.Text) + } + return clikit.FailEngine(clikit.ExitRuntime, "ENGINE_ERROR", msg, "", toClikitEngineError(res.EngineError)) + } + return cc.Result(execResult{Stdout: res.Stdout, Status: res.Status}, func() { + if res.Stdout != "" { + fmt.Fprint(cc.Stdout, res.Stdout) + if !strings.HasSuffix(res.Stdout, "\n") { + fmt.Fprintln(cc.Stdout) + } + } + fmt.Fprintln(cc.Stdout, cc.Faint(fmt.Sprintf("status %d", res.Status))) + }) +} + +// toClikitEngineError converts the SDK §7 fault to clikit's own copy (drivers +// convert at the envelope boundary — consistency-protocol). +func toClikitEngineError(e *mdriver.EngineError) *clikit.EngineError { + if e == nil { + return nil + } + return &clikit.EngineError{ + Routine: e.Routine, + Line: e.Line, + Mnemonic: e.Mnemonic, + Text: e.Text, + } +} diff --git a/exec_test.go b/exec_test.go new file mode 100644 index 0000000..000dccd --- /dev/null +++ b/exec_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/vista-cloud-dev/m-iris/internal/config" + "github.com/vista-cloud-dev/m-iris/internal/driver" +) + +// fakeRunnerAtelier serves the slice of Atelier the remote exec path needs: it +// accepts PUT/Compile (runner + IO helper + staged routines) and answers +// action/query by modeling the m_iris.* runner procedures against an in-memory +// result global. It is enough to drive exec load/run/eval end to end without a +// live IRIS (the real-engine tier covers the ObjectScript itself). +func fakeRunnerAtelier(t *testing.T) *httptest.Server { + t.Helper() + globals := map[string]string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/doc/"): + name := r.URL.Path[strings.Index(r.URL.Path, "/doc/")+len("/doc/"):] + _, _ = io.WriteString(w, `{"status":{"errors":[]},"result":{"name":"`+name+`","ts":"2026-06-12 00:00:00.000","status":"","content":[]}}`) + case strings.Contains(r.URL.Path, "/action/compile"): + _, _ = io.WriteString(w, `{"status":{"errors":[]},"result":{"content":[]}}`) + case strings.Contains(r.URL.Path, "/action/query"): + body, _ := io.ReadAll(r.Body) + var q struct { + Query string `json:"query"` + Parameters []string `json:"parameters"` + } + _ = json.Unmarshal(body, &q) + _, _ = io.WriteString(w, `{"status":{"errors":[]},"result":{"content":[`+answerQuery(q.Query, q.Parameters, globals)+`]}}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return srv +} + +// answerQuery models the m_iris.* SqlProcs the remote transport calls. +func answerQuery(sql string, params []string, globals map[string]string) string { + switch { + case strings.Contains(sql, "RunRef"): + rid := params[0] + // A clean run: status 0, done 1; the runner would have captured the + // entryref's device output into ^mIrisRun(rid,"out") — model "ran\n". + globals[`^mIrisRun("`+rid+`","out")`] = "ran\n" + globals[`^mIrisRun("`+rid+`","status")`] = "0" + globals[`^mIrisRun("`+rid+`","done")`] = "1" + return `{"status":"0"}` + case strings.Contains(sql, "Eval"): + rid := params[0] + globals[`^mIrisRun("`+rid+`","status")`] = "0" + globals[`^mIrisRun("`+rid+`","done")`] = "1" + return `{"status":"0"}` + case strings.Contains(sql, "GetOut"): + rid := params[0] + enc := base64.StdEncoding.EncodeToString([]byte(globals[`^mIrisRun("`+rid+`","out")`])) + return `{"out":` + jsonStr(enc) + `}` + case strings.Contains(sql, "GetGlobal"): + return `{"value":` + jsonStr(globals[params[0]]) + `}` + case strings.Contains(sql, "SELECT 1"): + return `{"one":"1"}` + } + return `{}` +} + +func jsonStr(s string) string { b, _ := json.Marshal(s); return string(b) } + +func execConn(base string) *config.Conn { + return &config.Conn{Transport: "remote", BaseURL: base + "/api/atelier/v1/", Namespace: "VISTA", User: "_SYSTEM", Password: "x"} +} + +// TestExecLoad_StagesDotMAsInt drives `exec load` over the fake Atelier and +// asserts the neutral .m source is staged under a .int docname (the SDK Client +// → driver seam v-pkg's install path rides). +func TestExecLoad_StagesDotMAsInt(t *testing.T) { + srv := fakeRunnerAtelier(t) + dir := t.TempDir() + path := filepath.Join(dir, "ZVPKGINS.m") + if err := os.WriteFile(path, []byte("ZVPKGINS ;gen\nEN ;\n W \"hi\",!\n Q\n"), 0o644); err != nil { + t.Fatal(err) + } + cc, buf := jsonCtx() + if err := (&execLoadCmd{Paths: []string{path}}).Run(cc, execConn(srv.URL)); err != nil { + t.Fatalf("exec load: %v", err) + } + var env struct { + OK bool `json:"ok"` + Data execLoadResult `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, buf.String()) + } + if !env.OK || len(env.Data.Loaded) != 1 || env.Data.Loaded[0] != "ZVPKGINS.int" { + t.Errorf("load = %+v, want one ZVPKGINS.int", env.Data) + } +} + +// TestExecRun_SurfacesStdout drives `exec run` and asserts the captured device +// output flows back as ExecResult.Stdout (the marker channel v-pkg parses). +func TestExecRun_SurfacesStdout(t *testing.T) { + srv := fakeRunnerAtelier(t) + cc, buf := jsonCtx() + if err := (&execRunCmd{EntryRef: "EN^ZVPKGINS"}).Run(cc, execConn(srv.URL)); err != nil { + t.Fatalf("exec run: %v", err) + } + var env struct { + Data execResult `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, buf.String()) + } + if env.Data.Stdout != "ran\n" { + t.Errorf("stdout = %q, want \"ran\\n\"", env.Data.Stdout) + } +} + +// TestExecEval_Runs drives `exec eval` (a single command) over the fake. +func TestExecEval_Runs(t *testing.T) { + srv := fakeRunnerAtelier(t) + cc, buf := jsonCtx() + if err := (&execEvalCmd{Command: []string{"set", "^x=1"}}).Run(cc, execConn(srv.URL)); err != nil { + t.Fatalf("exec eval: %v", err) + } + if !strings.Contains(buf.String(), `"status": 0`) { + t.Errorf("eval envelope = %s", buf.String()) + } +} + +// TestExecAxis_Advertised proves caps honestly advertises the exec axis and the +// CLI mounts it (conformance asserts advertised == implemented). +func TestExecAxis_Advertised(t *testing.T) { + if len(driver.CapsDoc().Axes.Exec) == 0 { + t.Error("caps does not advertise the exec axis") + } + if _, ok := any(CLI{}.Exec).(execCmd); !ok { + t.Error("CLI has no exec axis") + } +} diff --git a/internal/atelier/client.go b/internal/atelier/client.go index 35cbd0b..3538976 100644 --- a/internal/atelier/client.go +++ b/internal/atelier/client.go @@ -111,6 +111,13 @@ func New(cfg Config) (*Client, error) { // Namespace returns the namespace this client targets. func (c *Client) Namespace() string { return c.namespace } +// CloseIdleConnections drops pooled keep-alive connections. The remote exec path +// calls it after a run that can corrupt the IRIS gateway process's device (a +// KIDS install reconfigures the principal device, losing that request's response +// body), so the follow-up result reads open a fresh connection — a clean gateway +// process — instead of reusing the corrupted one. +func (c *Client) CloseIdleConnections() { c.hc.CloseIdleConnections() } + // endpoint builds an absolute URL for the given path segments under the base. // Segments are kept in URL.Path (decoded form) so URL.String() percent-encodes // reserved characters — important for routine names like "%ZVISTA.mac". diff --git a/internal/driver/caps.go b/internal/driver/caps.go index 6baed11..7786f03 100644 --- a/internal/driver/caps.go +++ b/internal/driver/caps.go @@ -27,6 +27,10 @@ func CapsDoc() mdriver.Caps { // M1 — lifecycle + health probes. provision/destroy are advertised but // report unsupported (exit 7) on the remote transport (risk B4). Lifecycle: []string{"up", "down", "restart", "status", "wait", "provision", "destroy"}, + // M3 — exec over the remote runner substrate. abort is not wired (the + // runner has no long-running job model on Atelier yet), so it stays off + // caps until implemented (honest-by-construction). + Exec: []string{"load", "run", "eval"}, }, Features: mdriver.Features{ Remote: true, // IRIS reaches over Atelier REST diff --git a/internal/driver/testdata/caps.golden.json b/internal/driver/testdata/caps.golden.json index f4bdf5e..dd9d1ca 100644 --- a/internal/driver/testdata/caps.golden.json +++ b/internal/driver/testdata/caps.golden.json @@ -26,6 +26,11 @@ "diff", "rm" ], + "exec": [ + "load", + "run", + "eval" + ], "meta": [ "caps", "version", diff --git a/internal/remote/integration_test.go b/internal/remote/integration_test.go index 2c96b30..23b0109 100644 --- a/internal/remote/integration_test.go +++ b/internal/remote/integration_test.go @@ -3,6 +3,8 @@ package remote import ( "context" "os" + "path/filepath" + "strings" "testing" "time" @@ -90,6 +92,69 @@ func TestRemoteSpike_RealEngine(t *testing.T) { t.Logf("engineError surfaced: %+v", res.EngineError) } +// TestRemoteExecAxis_RealEngine proves the exec-axis additions end to end on a +// real IRIS: a neutral ".m" routine stages under a ".int" docname (fix: docname +// mapping), and running its entryref returns the routine's WRITE output as +// ExecResult.Stdout (fix: runner device-output capture via mIrisIO). Together +// these are what `v pkg install --engine iris` rides; before the fixes, Load +// staged an unresolvable ".m" doc and Stdout came back empty. +// +// Gated identically to the spike (M_IRIS_IT=1 + M_IRIS_* connection env). +func TestRemoteExecAxis_RealEngine(t *testing.T) { + if os.Getenv("M_IRIS_IT") != "1" { + t.Skip("set M_IRIS_IT=1 (+ M_IRIS_* connection env) to run the real-engine exec-axis test") + } + client, err := atelier.New(atelier.Config{ + BaseURL: envOr("M_IRIS_BASE_URL", "http://localhost:52773/api/atelier/v1/"), + Namespace: envOr("M_IRIS_NAMESPACE", "USER"), + User: envOr("M_IRIS_USER", "_SYSTEM"), + Password: envOr("M_IRIS_PASSWORD", "SYS"), + Timeout: 30 * time.Second, + }) + if err != nil { + t.Fatalf("atelier client: %v", err) + } + tr := New(client) + ctx := context.Background() + + // Stage a scratch routine (label + space-indented body — the SDK routine-wrap + // shape) as a neutral ".m" file; Load must store it as ZZMIRISX.int. + dir := t.TempDir() + src := filepath.Join(dir, "ZZMIRISX.m") + body := "ZZMIRISX ;m-iris exec-axis IT — safe to delete\nEN ;\n W \"<>ok=1\",!\n Q\n" + if err := os.WriteFile(src, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = client.Query(ctx, "SELECT m_iris.KillGlobal(?)", `^mIrisRun("zzx")`) + _ = client.DeleteDoc(ctx, "ZZMIRISX.int") + _ = client.DeleteDoc(ctx, runnerDoc) + _ = client.DeleteDoc(ctx, ioHelperDoc) + }) + + lr, err := tr.Load(ctx, mdriver.LoadRequest{Paths: []string{src}}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if lr.EngineError != nil { + t.Fatalf("Load compile fault: %+v", lr.EngineError) + } + if len(lr.Loaded) != 1 || lr.Loaded[0] != "ZZMIRISX.int" { + t.Fatalf("Loaded = %v, want [ZZMIRISX.int]", lr.Loaded) + } + + res, err := tr.Exec(ctx, mdriver.ExecRequest{EntryRef: "EN^ZZMIRISX", Prefix: "zzx"}) + if err != nil { + t.Fatalf("Exec run: %v", err) + } + if res.EngineError != nil { + t.Fatalf("Exec fault: %+v", res.EngineError) + } + if !strings.Contains(res.Stdout, "<>ok=1") { + t.Fatalf("Stdout = %q, want it to contain the routine's WRITE output <>ok=1", res.Stdout) + } +} + func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/internal/remote/remote.go b/internal/remote/remote.go index 980bff3..21da0bf 100644 --- a/internal/remote/remote.go +++ b/internal/remote/remote.go @@ -10,11 +10,13 @@ package remote import ( "context" _ "embed" + "encoding/base64" "fmt" "os" "path/filepath" "strconv" "strings" + "time" mdriver "github.com/vista-cloud-dev/m-driver-sdk" "github.com/vista-cloud-dev/m-iris/internal/atelier" @@ -23,11 +25,22 @@ import ( //go:embed runner/m.iris.Runner.cls var runnerSource string +//go:embed runner/mIrisIO.int +var ioHelperSource string + // runnerDoc is the Atelier docname of the runner class. Package "m.iris" (dots, // no underscore — IRIS class names forbid underscores) projects its SqlProcs // into the SQL schema "m_iris", so the m_iris.* SQL calls below are unchanged. const runnerDoc = "m.iris.Runner.cls" +// ioHelperDoc is the Atelier docname of the companion IO-capture routine the +// runner's RunRef/Eval call (start^mIrisIO / stop^mIrisIO) to redirect a +// script's principal-device WRITE output into ^mIrisRun(rid,"out"). It is a +// classic .int routine because %Device.ReDirectIO dispatches each WRITE to +// mnemonic-space *routine* labels (wstr/wchr/wnl/…), which a class method +// cannot host. +const ioHelperDoc = "mIrisIO.int" + // AtelierAPI is the slice of the Atelier client the remote transport needs. It // is narrowed to an interface so unit tests inject a fake (recording PUT/Compile // and scripting Query rows) without an HTTP server — the real *atelier.Client is @@ -36,6 +49,10 @@ type AtelierAPI interface { PutDoc(ctx context.Context, name string, content []string) (*atelier.PutResult, error) Compile(ctx context.Context, names []string, flags string) (*atelier.CompileResult, error) Query(ctx context.Context, sql string, params ...string) ([]map[string]string, error) + // CloseIdleConnections drops pooled keep-alive connections so a follow-up + // query opens a fresh one (exec recovers a run's result over a clean process + // after a device-corrupting install). + CloseIdleConnections() } // Transport is the remote (Atelier REST + SQL runner) strategy. It satisfies @@ -61,7 +78,11 @@ func (t *Transport) ensureRunner(ctx context.Context) error { if _, err := t.api.PutDoc(ctx, runnerDoc, lines); err != nil { return fmt.Errorf("remote: deploy runner: %w", err) } - res, err := t.api.Compile(ctx, []string{runnerDoc}, "cuk") + ioLines := strings.Split(strings.TrimRight(ioHelperSource, "\n"), "\n") + if _, err := t.api.PutDoc(ctx, ioHelperDoc, irisRoutineLines(ioHelperDoc, ioLines)); err != nil { + return fmt.Errorf("remote: deploy IO helper: %w", err) + } + res, err := t.api.Compile(ctx, []string{runnerDoc, ioHelperDoc}, "cuk") if err != nil { return fmt.Errorf("remote: compile runner: %w", err) } @@ -90,41 +111,81 @@ func (t *Transport) Exec(ctx context.Context, req mdriver.ExecRequest) (mdriver. } rid := runID(req.Prefix) - var rows []map[string]string - var err error + var qerr error switch { case req.Command != "": - rows, err = t.api.Query(ctx, "SELECT m_iris.Eval(?,?) AS status", rid, req.Command) + _, qerr = t.api.Query(ctx, "SELECT m_iris.Eval(?,?) AS status", rid, req.Command) case req.EntryRef != "": - rows, err = t.api.Query(ctx, "SELECT m_iris.RunRef(?,?,?) AS status", + _, qerr = t.api.Query(ctx, "SELECT m_iris.RunRef(?,?,?) AS status", rid, req.EntryRef, strings.Join(req.Args, "\x01")) default: return mdriver.ExecResult{}, fmt.Errorf("remote: exec needs an entryref or a command") } - if err != nil { - return mdriver.ExecResult{}, err - } - status := firstCol(rows, "status") + // The run records status/out/error in ^mIrisRun(rid,*) and sets "done" last. + // A KIDS install (EN^XPDIJ) can corrupt THIS SqlProc's gateway process/device, + // so the action/query returns an empty/lost body (qerr) AND that process keeps + // spoiling responses for a moment — so don't trust qerr or the response row. + // Recover the outcome from the globals, retrying on fresh connections until a + // clean process serves the read; "done" gates it (missing → the run truly did + // not run). + status, out, eng, rerr := t.recoverRun(ctx, rid) + if rerr != nil { + if qerr != nil { + return mdriver.ExecResult{}, qerr + } + return mdriver.ExecResult{}, rerr + } switch status { case "7": return mdriver.ExecResult{}, fmt.Errorf("remote: runner refused — caller lacks the m_iris_runner role / action-query privilege") case "5": - eng, rerr := t.readEngineError(ctx, rid) - if rerr != nil { - return mdriver.ExecResult{}, rerr - } return mdriver.ExecResult{Status: 5, EngineError: eng}, nil } - - out, err := t.getGlobal(ctx, fmt.Sprintf(`^mIrisRun(%q,"out")`, rid)) - if err != nil { - return mdriver.ExecResult{}, err - } st, _ := strconv.Atoi(status) return mdriver.ExecResult{Stdout: out, Status: st}, nil } +// recoverRun reads a run's outcome (status, captured out, §7 fault) from +// ^mIrisRun(rid,*) after the run query. A device-corrupting install spoils the +// gateway process/connection that served the run, so the first read(s) may come +// back empty; retry, dropping pooled connections each time so a fresh one lands +// on a clean process, until "done" is readable (or the budget is exhausted). +func (t *Transport) recoverRun(ctx context.Context, rid string) (status, out string, eng *mdriver.EngineError, err error) { + doneRef := fmt.Sprintf(`^mIrisRun(%q,"done")`, rid) + statusRef := fmt.Sprintf(`^mIrisRun(%q,"status")`, rid) + var last error + for attempt := 0; attempt < 20; attempt++ { + t.api.CloseIdleConnections() + done, derr := t.getGlobal(ctx, doneRef) + if derr == nil && done == "1" { + st, serr := t.getGlobal(ctx, statusRef) + if serr != nil { + return "", "", nil, serr + } + if st == "5" { + e, eerr := t.readEngineError(ctx, rid) + return "5", "", e, eerr + } + o, oerr := t.getOut(ctx, rid) + if oerr != nil { + return "", "", nil, oerr + } + return st, o, nil, nil + } + last = derr + select { + case <-ctx.Done(): + return "", "", nil, ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } + if last != nil { + return "", "", nil, last + } + return "", "", nil, fmt.Errorf("remote: run did not complete (no result recorded for %q)", rid) +} + // readEngineError reads ^mIrisRun(rid,"error") and parses the §7 frame // "mnemonic|routine|line|text". func (t *Transport) readEngineError(ctx context.Context, rid string) (*mdriver.EngineError, error) { @@ -172,6 +233,32 @@ func (t *Transport) SetGlobal(ctx context.Context, ref, value string) error { return nil } +// getOut reads the captured result-global text for a run, Base64-encoded by the +// runner so control bytes (a KIDS install's ANSI/terminal output) survive the +// action/query JSON transport — a raw read truncates at the first non-text byte, +// dropping the trailing result markers v-pkg parses. IRIS Base64Encode may wrap +// the encoded text at 76 columns, so strip whitespace before decoding. +func (t *Transport) getOut(ctx context.Context, rid string) (string, error) { + rows, err := t.api.Query(ctx, "SELECT m_iris.GetOut(?) AS out", rid) + if err != nil { + return "", err + } + b64 := strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == ' ' || r == '\t' { + return -1 + } + return r + }, firstCol(rows, "out")) + if b64 == "" { + return "", nil + } + raw, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", fmt.Errorf("remote: decode captured output: %w", err) + } + return string(raw), nil +} + func (t *Transport) getGlobal(ctx context.Context, ref string) (string, error) { rows, err := t.api.Query(ctx, "SELECT m_iris.GetGlobal(?) AS value", ref) if err != nil { @@ -194,8 +281,8 @@ func (t *Transport) Load(ctx context.Context, req mdriver.LoadRequest) (mdriver. if rerr != nil { return mdriver.LoadResult{}, rerr } - name := req.Prefix + filepath.Base(f) - if _, perr := t.api.PutDoc(ctx, name, splitLines(string(content))); perr != nil { + name := req.Prefix + irisDocname(filepath.Base(f)) + if _, perr := t.api.PutDoc(ctx, name, irisRoutineLines(name, splitLines(string(content)))); perr != nil { return mdriver.LoadResult{}, perr } loaded = append(loaded, name) @@ -208,6 +295,42 @@ func (t *Transport) Load(ctx context.Context, req mdriver.LoadRequest) (mdriver. return mdriver.LoadResult{Loaded: loaded}, nil } +// irisDocname maps a routine-source basename to a valid IRIS Atelier docname. +// The neutral routine extension ".m" (what m-cli / the SDK Client and v-pkg +// stage) is NOT an Atelier routine type, so a ".m" doc never stages and a +// later `exec run EN^` cannot resolve it. Map it to ".int" — classic +// MUMPS intermediate code, matching the label + space-indented body the SDK +// routine-wrap emits. Names that already carry an IRIS extension +// (.mac/.int/.inc/.cls) pass through unchanged. +func irisDocname(base string) string { + if strings.EqualFold(filepath.Ext(base), ".m") { + return strings.TrimSuffix(base, filepath.Ext(base)) + ".int" + } + return base +} + +// irisRoutineLines ensures a routine doc carries the UDL header Atelier requires +// as its first line — `ROUTINE [Type=INT|MAC|INC]` — derived from the +// docname. Without it the server rejects the PUT (#16021 "Illegal Header Line") +// even though the body's first line is a valid routine label. A doc that already +// leads with a `ROUTINE ` header (e.g. one round-tripped out of IRIS) or a +// non-routine type (.cls carries its own `Class …` header) passes through +// unchanged. +func irisRoutineLines(docname string, lines []string) []string { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(docname), ".")) + switch ext { + case "int", "mac", "inc": + default: + return lines + } + if len(lines) > 0 && strings.HasPrefix(lines[0], "ROUTINE ") { + return lines + } + name := strings.TrimSuffix(filepath.Base(docname), filepath.Ext(docname)) + header := fmt.Sprintf("ROUTINE %s [Type=%s]", name, strings.ToUpper(ext)) + return append([]string{header}, lines...) +} + // Health proves the remote substrate is reachable AND that the caller actually // holds the action/query privilege (a SELECT 1, not just TCP reachability — // risks C3, C7). Version enrichment lands with the M1 root-endpoint probe. diff --git a/internal/remote/remote_test.go b/internal/remote/remote_test.go index 2359096..98ddd17 100644 --- a/internal/remote/remote_test.go +++ b/internal/remote/remote_test.go @@ -2,6 +2,10 @@ package remote import ( "context" + "encoding/base64" + "fmt" + "os" + "path/filepath" "strings" "testing" @@ -14,6 +18,7 @@ import ( // dispatching on the SQL + bound parameters, modelling ^mIrisRun. type fakeAPI struct { puts []string + putBody map[string][]string // docname → content (last PUT) compiles [][]string globals map[string]string // global ref → value (the result global) runFault *clikit3Engine // if set, the next RunRef faults with this frame @@ -22,13 +27,35 @@ type fakeAPI struct { // clikit3Engine mirrors the runner's "mnemonic|routine|line|text" error frame. type clikit3Engine struct{ mnemonic, routine, line, text string } -func newFakeAPI() *fakeAPI { return &fakeAPI{globals: map[string]string{}} } +func newFakeAPI() *fakeAPI { + return &fakeAPI{globals: map[string]string{}, putBody: map[string][]string{}} +} -func (f *fakeAPI) PutDoc(_ context.Context, name string, _ []string) (*atelier.PutResult, error) { +// PutDoc models the real Atelier rejection a fake without it misses: a routine +// doc (.int/.mac/.inc) whose first line is not a `ROUTINE … [Type=…]` UDL header +// is refused #16021 ("Illegal Header Line"), so the transport MUST add the header +// (irisRoutineLines) before staging. +func (f *fakeAPI) PutDoc(_ context.Context, name string, content []string) (*atelier.PutResult, error) { + switch ext(name) { + case "int", "mac", "inc": + if len(content) == 0 || !strings.HasPrefix(content[0], "ROUTINE ") { + return nil, fmt.Errorf("atelier: PUT %q rejected: ERROR #16021: Illegal Header Line", name) + } + } f.puts = append(f.puts, name) + f.putBody[name] = content return &atelier.PutResult{Name: name}, nil } +func (f *fakeAPI) CloseIdleConnections() {} + +func ext(name string) string { + if i := strings.LastIndex(name, "."); i >= 0 { + return strings.ToLower(name[i+1:]) + } + return "" +} + func (f *fakeAPI) Compile(_ context.Context, names []string, _ string) (*atelier.CompileResult, error) { f.compiles = append(f.compiles, names) return &atelier.CompileResult{}, nil @@ -38,6 +65,7 @@ func (f *fakeAPI) Query(_ context.Context, sql string, params ...string) ([]map[ switch { case strings.Contains(sql, "RunRef"): rid := params[0] + f.globals[`^mIrisRun("`+rid+`","done")`] = "1" if f.runFault != nil { f.globals[`^mIrisRun("`+rid+`","status")`] = "5" f.globals[`^mIrisRun("`+rid+`","error")`] = strings.Join( @@ -47,7 +75,14 @@ func (f *fakeAPI) Query(_ context.Context, sql string, params ...string) ([]map[ f.globals[`^mIrisRun("`+rid+`","status")`] = "0" return []map[string]string{{"status": "0"}}, nil case strings.Contains(sql, "Eval"): + rid := params[0] + f.globals[`^mIrisRun("`+rid+`","done")`] = "1" + f.globals[`^mIrisRun("`+rid+`","status")`] = "0" return []map[string]string{{"status": "0"}}, nil + case strings.Contains(sql, "GetOut"): + rid := params[0] + enc := base64.StdEncoding.EncodeToString([]byte(f.globals[`^mIrisRun("`+rid+`","out")`])) + return []map[string]string{{"out": enc}}, nil case strings.Contains(sql, "SetGlobal"): f.globals[params[0]] = params[1] return []map[string]string{{"ok": "1"}}, nil @@ -73,9 +108,9 @@ func TestRemoteExec_DeploysRunnerOnceAndRunsClean(t *testing.T) { if res.Status != 0 || res.EngineError != nil { t.Errorf("clean run = %+v, want status 0 no engineError", res) } - // Runner deployed exactly once... - if len(api.puts) != 1 || api.puts[0] != runnerDoc { - t.Errorf("puts = %v, want one %s", api.puts, runnerDoc) + // Runner + IO helper deployed exactly once, in one compile... + if len(api.puts) != 2 || api.puts[0] != runnerDoc || api.puts[1] != ioHelperDoc { + t.Errorf("puts = %v, want [%s %s]", api.puts, runnerDoc, ioHelperDoc) } if len(api.compiles) != 1 { t.Errorf("compiles = %v, want one", api.compiles) @@ -84,7 +119,7 @@ func TestRemoteExec_DeploysRunnerOnceAndRunsClean(t *testing.T) { if _, err := tr.Exec(ctx, mdriver.ExecRequest{EntryRef: "OTHER^RTN", Prefix: "zzt42"}); err != nil { t.Fatalf("second Exec: %v", err) } - if len(api.puts) != 1 { + if len(api.puts) != 2 { t.Errorf("runner re-deployed: puts = %v", api.puts) } } @@ -109,6 +144,47 @@ func TestRemoteExec_FaultBecomesEngineError(t *testing.T) { } } +// TestLoad_MapsDotMToIntDocname proves Load stages a neutral ".m" routine +// source under a valid IRIS routine docname (".int", classic MUMPS) — ".m" is +// not an Atelier routine extension, so a docname kept as "ZVPKGINS.m" would +// never stage and `exec run EN^ZVPKGINS` would then fail to resolve. Other +// extensions (already-valid IRIS docnames) pass through unchanged. +func TestLoad_MapsDotMToIntDocname(t *testing.T) { + dir := t.TempDir() + dotM := filepath.Join(dir, "ZVPKGINS.m") + if err := os.WriteFile(dotM, []byte("ZVPKGINS ;gen\nEN ;\n Q\n"), 0o644); err != nil { + t.Fatal(err) + } + dotMac := filepath.Join(dir, "ALREADY.mac") + if err := os.WriteFile(dotMac, []byte("ALREADY ;x\n q\n"), 0o644); err != nil { + t.Fatal(err) + } + + api := newFakeAPI() + tr := New(api) + res, err := tr.Load(context.Background(), mdriver.LoadRequest{Paths: []string{dotM, dotMac}}) + if err != nil { + t.Fatalf("Load: %v", err) + } + wantLoaded := []string{"ZVPKGINS.int", "ALREADY.mac"} + if len(res.Loaded) != 2 || res.Loaded[0] != wantLoaded[0] || res.Loaded[1] != wantLoaded[1] { + t.Errorf("Loaded = %v, want %v", res.Loaded, wantLoaded) + } + // The runner is PUT once (ensureRunner) plus the two staged docs; assert the + // staged docnames, not the runner doc. + staged := api.puts[len(api.puts)-2:] + if staged[0] != "ZVPKGINS.int" { + t.Errorf("staged .m doc = %q, want ZVPKGINS.int", staged[0]) + } + if staged[1] != "ALREADY.mac" { + t.Errorf("staged .mac doc = %q, want ALREADY.mac (unchanged)", staged[1]) + } + // The staged .int must lead with the UDL routine header (else Atelier #16021). + if got := api.putBody["ZVPKGINS.int"][0]; got != "ROUTINE ZVPKGINS [Type=INT]" { + t.Errorf("ZVPKGINS.int header = %q, want ROUTINE ZVPKGINS [Type=INT]", got) + } +} + // TestRemoteData_SetGetRoundTrip proves data.set/get ride the same substrate. func TestRemoteData_SetGetRoundTrip(t *testing.T) { api := newFakeAPI() diff --git a/internal/remote/runner/m.iris.Runner.cls b/internal/remote/runner/m.iris.Runner.cls index 6b5778a..c43ec27 100644 --- a/internal/remote/runner/m.iris.Runner.cls +++ b/internal/remote/runner/m.iris.Runner.cls @@ -51,10 +51,18 @@ ClassMethod RunRef(rid As %String, ref As %String, args As %String = "") As %Int kill ^mIrisRun(rid) set ^mIrisRun(rid,"status")=0 try { + do start^mIrisIO(rid) do @ref + do stop^mIrisIO() } catch ex { + do stop^mIrisIO() do ..fault(rid,ex) } + // "done" lets the caller recover the outcome from ^mIrisRun even when this + // SqlProc's HTTP response body is lost — a KIDS install can corrupt the + // response device so the action/query returns an empty body, but the run's + // status/out/error are already committed to the global here. + set ^mIrisRun(rid,"done")=1 quit ^mIrisRun(rid,"status") } @@ -66,13 +74,29 @@ ClassMethod Eval(rid As %String, cmd As %String) As %Integer [ SqlName = "Eval", kill ^mIrisRun(rid) set ^mIrisRun(rid,"status")=0 try { + do start^mIrisIO(rid) xecute cmd + do stop^mIrisIO() } catch ex { + do stop^mIrisIO() do ..fault(rid,ex) } + set ^mIrisRun(rid,"done")=1 quit ^mIrisRun(rid,"status") } +/// GetOut returns ^mIrisRun(rid,"out") Base64-encoded so a runner result that +/// contains control bytes — a KIDS install's ANSI/terminal output (ESC, CR, page +/// controls) — survives the action/query JSON transport intact. A raw read of +/// such a value truncates/mangles at the first non-text byte, dropping the +/// trailing result markers. (contract: Stdout = runner result-global text.) +/// SQL: SELECT m_iris.GetOut(?) +ClassMethod GetOut(rid As %String) As %String [ SqlName = "GetOut", SqlProc ] +{ + if '..authorized() quit "" + quit $system.Encryption.Base64Encode($get(^mIrisRun(rid,"out"))) +} + /// GetGlobal returns $get(@ref); ref is a full global reference, e.g. /// "^mIrisRun(""r1"",""status"")". (contract data.get + result-global reads) /// SQL: SELECT m_iris.GetGlobal(?) diff --git a/internal/remote/runner/mIrisIO.int b/internal/remote/runner/mIrisIO.int new file mode 100644 index 0000000..93c91c1 --- /dev/null +++ b/internal/remote/runner/mIrisIO.int @@ -0,0 +1,82 @@ +ROUTINE mIrisIO [Type=INT] +mIrisIO ;m-iris remote IO-capture helper ;2026-06-12 + ;; Companion to m.iris.Runner. Atelier/SQL has no principal device a script's + ;; WRITE output can be read back from, so the remote runner captures it: before + ;; `do @ref` / `xecute`, start() turns on %Device.ReDirectIO and points the + ;; principal device's mnemonic space here; every WRITE then calls the w* labels + ;; below, which append to ^mIrisRun(rid,"out"). That global is what + ;; Transport.Exec returns as ExecResult.Stdout (contract: "Stdout = runner + ;; result-global text on IRIS remote") — so a script's `<>key=val` markers, + ;; written with WRITE, survive even a KIDS install's `D HOME^%ZIS`/device churn. + ;; + ;; Teardown MUST NOT throw: a KIDS install leaves the SQL process's device in a + ;; mangled state (it emits ANSI terminal control), so `use io` to restore can + ;; fault. If that fault escaped, the runner's SqlProc would return HTTP 500 even + ;; though `do @ref` ran clean and the markers were captured. So stop() (and + ;; start's defensive pre-clear) swallow teardown errors and just guarantee + ;; ReDirectIO is OFF before the SqlProc returns its result. + quit + ; +start(rid) ;Begin capturing principal-device WRITE output into ^mIrisRun(rid,"out"). + try { do ##class(%Device).ReDirectIO(0) } catch e1 { } + set ^mIrisRun(rid,"out")=$get(^mIrisRun(rid,"out")) + set ^||mIrisIO("rid")=rid + set ^||mIrisIO("io")=$io + set ^||mIrisIO("mn")=##class(%Device).GetMnemonicRoutine() + do ##class(%Device).ReDirectIO(1) + use $io::("^mIrisIO") + quit + ; +stop() ;Stop capturing and restore the prior device + mnemonic — never throwing. + ;; Restoring the mnemonic-space routine matters as much as ReDirectIO(0): start() + ;; pointed the principal device's mnemonic at ^mIrisIO, and the action/query + ;; framework writes its HTTP response through that device. If the mnemonic still + ;; pointed here, the response write would dispatch into this routine instead of + ;; the socket → HTTP 500. Restore the device's original mnemonic so the response + ;; goes out cleanly. + try { do ##class(%Device).ReDirectIO(0) } catch e2 { } + try { + new io,mn + set io=$get(^||mIrisIO("io")) + set mn=$get(^||mIrisIO("mn")) + if io'="" { + if mn'="" { use io::("^"_mn) } else { use io } + } + } catch e3 { } + kill ^||mIrisIO + quit + ; +install(rid,ref) ;Child-JOB entry: run ref in an isolated process, capturing its + ;; output into ^mIrisRun(rid,"out"). A KIDS install (EN^XPDIJ) reconfigures the + ;; principal device with USE-params that ReDirectIO does NOT intercept; doing + ;; that in the SqlProc's own process corrupts the SQL/HTTP response device, so + ;; the action/query returns HTTP 500 even though the install ran clean and the + ;; markers were captured. Confining it to a JOB'd child discards that churn the + ;; instant the child ends — the parent SqlProc's response device stays pristine. + ;; Result rides ^mIrisRun (a global, visible to the parent); "done" signals it. + try { + do start(rid) + do @ref + do stop() + } catch ex { + do stop() + set ^mIrisRun(rid,"status")=5 + set ^mIrisRun(rid,"error")=ex.Name_"|"_$piece(ex.Location,"^",2)_"|"_$piece($piece(ex.Location,"^",1),"+",2)_"|"_ex.DisplayString() + } + set ^mIrisRun(rid,"done")=1 + quit + ; +app(s) ;Append s to the active run's out global (no-op if capture is off). + new rid + set rid=$get(^||mIrisIO("rid")) + if rid="" quit + set ^mIrisRun(rid,"out")=$get(^mIrisRun(rid,"out"))_s + quit + ; + ;; %Device.ReDirectIO entry points (it dispatches each WRITE to these labels in + ;; the current mnemonic-space routine): +wstr(s) do app(s) quit +wchr(c) do app($char(c)) quit +wnl do app($char(13,10)) quit +wff do app($char(12)) quit +wtab(n) do app($justify("",$select(n>$x:n-$x,1:0))) quit diff --git a/main.go b/main.go index f2bce30..65fc4cf 100644 --- a/main.go +++ b/main.go @@ -48,6 +48,7 @@ type CLI struct { Meta metaCmd `cmd:"" help:"Introspection + power tools: caps / info / version / schema."` Lifecycle lifecycleCmd `cmd:"" help:"Manage the engine instance: up / down / restart / status / wait / provision / destroy."` Sync syncCmd `cmd:"" help:"Source axis: routine source ↔ instance (list / pull / status / verify / push / deploy / diff / rm)."` + Exec execCmd `cmd:"" help:"Exec axis: run M against the namespace (load / run / eval) via the remote runner."` InstallCompletions kongplete.InstallCompletions `cmd:"" help:"Install shell tab-completions."` } From 95820e1018b57c84b47e43c432004bc6050e6d0f Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Fri, 12 Jun 2026 15:38:35 -0400 Subject: [PATCH 16/24] feat(exec): wire exec abort over the remote runner (live-proven on m-test-iris) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last exec verb. Abort over the synchronous Atelier path needs a live target, so the runner now records its own $job in ^mIrisRun(rid,"pid") (set right after status; "done", set last, marks completion). New m_iris.Abort(rid) SqlProc terminates the recorded pid via $system.Process.Terminate(pid,2), guarded by a ^$JOB(pid) liveness check, a self-check (never $job), and the "done" flag; it returns the pid, "" when nothing is live (parity with m-ydb "no jobs matched"), or "DENIED" on a role failure. remote.Transport.Abort -> exec abort --prefix (driver-local, not an SDK Transport verb — same shape as m-ydb's Session.Abort). caps Exec is now [load,run,eval,abort]. IRIS facts caught live: $ZCHILD is a YottaDB-ism ( in IRIS) so we capture the run's own $job, not a child pid; ^$JOB(pid) is the M-native liveness check. Gates: go test -race ./... + gofmt + vet green; make test-it green vs m-test-iris (new TestRemoteAbort_RealEngine aborts a live `hang 30`, reports the pid, second abort finds nothing); SDK conformance 16/16 remote. M3 stays in progress: local/docker `iris session` transports remain before it flips to done. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/m-iris-tracker.md | 2 +- docs/memory/m-iris-exec-axis-t0a5.md | 21 +++++- exec.go | 38 ++++++++++- internal/driver/caps.go | 8 +-- internal/driver/testdata/caps.golden.json | 3 +- internal/remote/integration_test.go | 83 +++++++++++++++++++++++ internal/remote/remote.go | 26 +++++++ internal/remote/remote_test.go | 42 ++++++++++++ internal/remote/runner/m.iris.Runner.cls | 21 ++++++ 9 files changed, 233 insertions(+), 11 deletions(-) diff --git a/docs/m-iris-tracker.md b/docs/m-iris-tracker.md index a03a759..ba975cd 100644 --- a/docs/m-iris-tracker.md +++ b/docs/m-iris-tracker.md @@ -13,7 +13,7 @@ Pinned: `m-driver-sdk v0.2.0`. Branch: `m-iris-driver`. Transports: local·docke | M0 | scaffold + SDK seam + `meta` | ☑ | honest caps golden; rename irissync→m-iris | | M1 | lifecycle + health + doctor | ☑ | remote/attach; real-IRIS 2026.1 validated | | M2 | sync (8 verbs) | ☑ | diff/rm/push --from/bare-name filter; real-IRIS green (404 + PutDoc bugs fixed) | -| M3 | exec (load/run/eval/abort) + engineError | ◐ | **exec `load`/`run`/`eval` WIRED over the remote runner (2026-06-12)** — `exec.go` + `execCmd` mounted in `CLI`; caps advertises `exec`; IRIS fault→§7 engineError; the SDK reference `Client` now drives a live VistA over the seam. Device `W` output is now CAPTURED (see device-capture note below). **T0a.5 driver-path PROVEN on foia** (`v pkg install/verify/uninstall --engine iris` — all 3 M0a invariants green, deterministic). `--prefix` on run; `abort` + local/docker `iris session` transports still ☐. | +| M3 | exec (load/run/eval/abort) + engineError | ◐ | **exec `load`/`run`/`eval` WIRED over the remote runner (2026-06-12)** — `exec.go` + `execCmd` mounted in `CLI`; caps advertises `exec`; IRIS fault→§7 engineError; the SDK reference `Client` now drives a live VistA over the seam. Device `W` output is now CAPTURED (see device-capture note below). **T0a.5 driver-path PROVEN on foia** (`v pkg install/verify/uninstall --engine iris` — all 3 M0a invariants green, deterministic). **`exec abort` WIRED + live-proven (2026-06-12)** — runner records each run's `$job` in `^mIrisRun(rid,"pid")` (set right after status, cleared-by-"done"); new `m_iris.Abort(rid)` SqlProc terminates a live, not-`done` pid via `$system.Process.Terminate(pid,2)` guarded by `^$JOB(pid)` liveness + self-check, returns the pid (`"DENIED"`=role-fail, `""`=nothing live); `remote.Transport.Abort`→`exec abort --prefix`; caps Exec now `[load,run,eval,abort]`. `TestRemoteAbort_RealEngine` aborts a live `hang 30` on m-test-iris (reports pid; second abort finds nothing). Conformance **16/16 remote**. **Remaining for M3 ☑: local/docker `iris session` transports** (docker-exec into m-test-iris is reachable here — validatable). | | M4 | data (get/set/kill/query/export/import) | ☐ | remote via runner, SQL-wrapped | | M5 | cover (%Monitor.LineByLine → LCOV) | ☐ | port mcov.FromMonitor | | M6 | admin (backup/restore/check/journal) | ☐ | | diff --git a/docs/memory/m-iris-exec-axis-t0a5.md b/docs/memory/m-iris-exec-axis-t0a5.md index 7986b68..43da4e5 100644 --- a/docs/memory/m-iris-exec-axis-t0a5.md +++ b/docs/memory/m-iris-exec-axis-t0a5.md @@ -1,9 +1,10 @@ --- name: m-iris-exec-axis-t0a5 -description: m-iris exec axis (load/run/eval) wired over the remote runner + the device-output capture machinery that closed VSL M0a's T0a.5 IRIS driver-path on foia. Has the hard-won KIDS-over-Atelier device-corruption findings. -metadata: +description: "m-iris exec axis (load/run/eval) wired over the remote runner + the device-output capture machinery that closed VSL M0a's T0a.5 IRIS driver-path on foia. Has the hard-won KIDS-over-Atelier device-corruption findings." +metadata: node_type: memory type: project + originSessionId: 70bf5dbe-39a1-44d9-9439-a19b1fdfbe39 --- **M0a T0a.5 driver-path PROVEN on IRIS FOIA (foia) 2026-06-12.** `v pkg @@ -32,6 +33,22 @@ argument exec`, exit 2, which the Client swallowed). Fixed, all in m-iris: with `ROUTINE `, and `.cls`). **The fake tier missed this — only the live engine caught it** (encoded back into the fake's `PutDoc` as a #16021 guard). +## exec abort (added 2026-06-12 — closes the last exec verb) +Abort over the synchronous Atelier path needs a live target, so the runner now +records its OWN `$job` into `^mIrisRun(rid,"pid")` (set right after `status`, and +"done" — set last — marks completion). New `m_iris.Abort(rid)` SqlProc: terminates +the recorded pid via `$system.Process.Terminate(pid,2)` **iff** pid set ∧ no "done" +∧ pid≠`$job` (never self) ∧ `^$JOB(pid)` defined (process still live); returns the +pid, `""` (nothing live — the common case, parity with m-ydb "no jobs matched"), or +`"DENIED"` (role-fail). `remote.Transport.Abort(ctx,prefix)→[]string` (driver-local, +NOT an SDK `Transport` verb — same as m-ydb's `Session.Abort`); `exec abort --prefix` +in exec.go; caps Exec `[load,run,eval,abort]`. **Live-proven** `TestRemoteAbort_ +RealEngine` on m-test-iris: two transports — one runs `hang 30`, the other aborts by +prefix, gets the pid, the blocked call returns, second abort finds nothing. +**IRIS gotchas (live-caught):** `$ZCHILD` is a YottaDB-ism — `` in IRIS (so +capture the run's own `$job`, not a JOB'd child's); `^$JOB(pid)` is the M-native +liveness check; `$system.Process.Terminate(pid,2)`→1 and the process dies. + ## Device-output capture (the deepest part — 4 layered findings) Atelier/SQL has no principal device a script's `W` output can be read from. The runner now captures it. Each layer below was a separate live-only failure: diff --git a/exec.go b/exec.go index 9ae46b5..5b0d975 100644 --- a/exec.go +++ b/exec.go @@ -20,9 +20,10 @@ import ( // — the substrate the remote spike de-risked; this axis wires it to the CLI so // the SDK reference Client (and therefore `v pkg install`) can drive a lifecycle. type execCmd struct { - Load execLoadCmd `cmd:"" name:"load" help:"Stage routine source into the namespace (Atelier PUT) and compile it; neutral .m → .int. Compile faults surface as engineError."` - Run execRunCmd `cmd:"" name:"run" help:"Run an entryref (LABEL^ROUTINE) through the runner; args → the formallist. Faults surface as engineError."` - Eval execEvalCmd `cmd:"" name:"eval" help:"Evaluate a single M command through the runner. Faults surface as engineError."` + Load execLoadCmd `cmd:"" name:"load" help:"Stage routine source into the namespace (Atelier PUT) and compile it; neutral .m → .int. Compile faults surface as engineError."` + Run execRunCmd `cmd:"" name:"run" help:"Run an entryref (LABEL^ROUTINE) through the runner; args → the formallist. Faults surface as engineError."` + Eval execEvalCmd `cmd:"" name:"eval" help:"Evaluate a single M command through the runner. Faults surface as engineError."` + Abort execAbortCmd `cmd:"" name:"abort" help:"Stop a run still in flight under an ephemeral --prefix (the runner terminates its recorded process)."` } type execResult struct { @@ -90,6 +91,37 @@ func (c *execRunCmd) Run(cc *clikit.Context, conn *config.Conn) error { return runExec(cc, conn, mdriver.ExecRequest{EntryRef: c.EntryRef, Args: c.Args, Prefix: c.Prefix}) } +// --- abort ------------------------------------------------------------------- + +type execAbortCmd struct { + Prefix string `help:"Ephemeral-run prefix to abort (the run id passed to 'exec run --prefix')." placeholder:"PREFIX"` +} + +type execAbortResult struct { + Killed []string `json:"killed"` +} + +func (c *execAbortCmd) Run(cc *clikit.Context, conn *config.Conn) error { + if c.Prefix == "" { + return clikit.Fail(clikit.ExitUsage, "NO_PREFIX", "exec abort needs --prefix", "") + } + tr, err := remoteTransport(conn) + if err != nil { + return err + } + killed, err := tr.Abort(context.Background(), c.Prefix) + if err != nil { + return runtimeErr(err) + } + return cc.Result(execAbortResult{Killed: nonNil(killed)}, func() { + if len(killed) == 0 { + fmt.Fprintln(cc.Stdout, cc.Faint("no run in flight under --prefix "+c.Prefix)) + return + } + fmt.Fprintln(cc.Stdout, cc.Success(fmt.Sprintf("aborted %d run(s): %s", len(killed), strings.Join(killed, ", ")))) + }) +} + // --- eval -------------------------------------------------------------------- type execEvalCmd struct { diff --git a/internal/driver/caps.go b/internal/driver/caps.go index 7786f03..41e7c5b 100644 --- a/internal/driver/caps.go +++ b/internal/driver/caps.go @@ -27,10 +27,10 @@ func CapsDoc() mdriver.Caps { // M1 — lifecycle + health probes. provision/destroy are advertised but // report unsupported (exit 7) on the remote transport (risk B4). Lifecycle: []string{"up", "down", "restart", "status", "wait", "provision", "destroy"}, - // M3 — exec over the remote runner substrate. abort is not wired (the - // runner has no long-running job model on Atelier yet), so it stays off - // caps until implemented (honest-by-construction). - Exec: []string{"load", "run", "eval"}, + // M3 — exec over the remote runner substrate. abort stops a run still + // in flight under its ephemeral --prefix (the runner records each run's + // $job and terminates a live, not-done process). + Exec: []string{"load", "run", "eval", "abort"}, }, Features: mdriver.Features{ Remote: true, // IRIS reaches over Atelier REST diff --git a/internal/driver/testdata/caps.golden.json b/internal/driver/testdata/caps.golden.json index dd9d1ca..dfc8ae0 100644 --- a/internal/driver/testdata/caps.golden.json +++ b/internal/driver/testdata/caps.golden.json @@ -29,7 +29,8 @@ "exec": [ "load", "run", - "eval" + "eval", + "abort" ], "meta": [ "caps", diff --git a/internal/remote/integration_test.go b/internal/remote/integration_test.go index 23b0109..ed122a8 100644 --- a/internal/remote/integration_test.go +++ b/internal/remote/integration_test.go @@ -155,6 +155,89 @@ func TestRemoteExecAxis_RealEngine(t *testing.T) { } } +// TestRemoteAbort_RealEngine proves exec.abort terminates a run still in flight +// on a real IRIS: one transport launches a long (`hang`) Eval that registers its +// process in ^mIrisRun(rid,"pid"); a second transport aborts it by prefix and +// reports the terminated pid; the launching call then returns promptly because +// its server process was killed. (Over the synchronous Atelier path a completed +// run leaves nothing to abort — this test is the positive, concurrent case.) +// +// Gated identically to the spike (M_IRIS_IT=1 + M_IRIS_* connection env). +func TestRemoteAbort_RealEngine(t *testing.T) { + if os.Getenv("M_IRIS_IT") != "1" { + t.Skip("set M_IRIS_IT=1 (+ M_IRIS_* connection env) to run the real-engine abort test") + } + cfg := atelier.Config{ + BaseURL: envOr("M_IRIS_BASE_URL", "http://localhost:52773/api/atelier/v1/"), + Namespace: envOr("M_IRIS_NAMESPACE", "USER"), + User: envOr("M_IRIS_USER", "_SYSTEM"), + Password: envOr("M_IRIS_PASSWORD", "SYS"), + Timeout: 60 * time.Second, + } + // Two independent clients: one blocks on the long run, the other aborts it. + runClient, err := atelier.New(cfg) + if err != nil { + t.Fatalf("atelier client (run): %v", err) + } + ctlClient, err := atelier.New(cfg) + if err != nil { + t.Fatalf("atelier client (ctl): %v", err) + } + runTr, ctlTr := New(runClient), New(ctlClient) + ctx := context.Background() + const rid = "zzMIRISABORT" + + t.Cleanup(func() { + _, _ = ctlClient.Query(ctx, "SELECT m_iris.KillGlobal(?)", `^mIrisRun("`+rid+`")`) + }) + + // Launch a long-running run; it sets ^mIrisRun(rid,"pid")=$job then hangs. + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = runTr.Exec(ctx, mdriver.ExecRequest{Command: "hang 30", Prefix: rid}) + }() + + // Wait until the run has registered its process (deploys the runner on the + // ctl transport's first read; idempotent with the run transport's deploy). + var pid string + for i := 0; i < 100; i++ { + node, rerr := ctlTr.ReadGlobal(ctx, mdriver.GlobalRef{Ref: `^mIrisRun("` + rid + `","pid")`}) + if rerr == nil && node.Value != "" { + pid = node.Value + break + } + time.Sleep(100 * time.Millisecond) + } + if pid == "" { + t.Fatal("run never registered a pid — cannot test abort") + } + + killed, err := ctlTr.Abort(ctx, rid) + if err != nil { + t.Fatalf("Abort: %v", err) + } + if len(killed) != 1 || killed[0] != pid { + t.Fatalf("killed = %v, want [%s]", killed, pid) + } + + // The terminated run's blocking call must return promptly now. + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("aborted run did not return after its process was terminated") + } + + // A second abort finds nothing live (idempotent / honest). + again, err := ctlTr.Abort(ctx, rid) + if err != nil { + t.Fatalf("second Abort: %v", err) + } + if len(again) != 0 { + t.Errorf("second abort killed = %v, want none", again) + } +} + func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/internal/remote/remote.go b/internal/remote/remote.go index 21da0bf..9b89904 100644 --- a/internal/remote/remote.go +++ b/internal/remote/remote.go @@ -210,6 +210,32 @@ func (t *Transport) readEngineError(ctx context.Context, rid string) (*mdriver.E return eng, nil } +// Abort stops a run still in flight under the ephemeral prefix (contract +// exec.abort). The runner records each run's process and clears it on completion, +// so abort terminates a live, not-yet-done run and reports the pid; a synchronous +// run that has already returned leaves nothing to stop (killed is empty — parity +// with m-ydb's "no jobs matched"). Abort is a driver-local exec verb, not a +// neutral Transport method (the SDK Transport has no Abort) — like m-ydb's +// Session.Abort. +func (t *Transport) Abort(ctx context.Context, prefix string) ([]string, error) { + if err := t.ensureRunner(ctx); err != nil { + return nil, err + } + rows, err := t.api.Query(ctx, "SELECT m_iris.Abort(?) AS pid", prefix) + if err != nil { + return nil, err + } + pid := firstCol(rows, "pid") + switch pid { + case "DENIED": + return nil, fmt.Errorf("remote: runner refused abort — caller lacks the m_iris_runner role / action-query privilege") + case "": + return nil, nil + default: + return []string{pid}, nil + } +} + // ReadGlobal reads a single global node via the runner (contract data.get). func (t *Transport) ReadGlobal(ctx context.Context, req mdriver.GlobalRef) (mdriver.GlobalNode, error) { if err := t.ensureRunner(ctx); err != nil { diff --git a/internal/remote/remote_test.go b/internal/remote/remote_test.go index 98ddd17..edb9f8d 100644 --- a/internal/remote/remote_test.go +++ b/internal/remote/remote_test.go @@ -83,6 +83,16 @@ func (f *fakeAPI) Query(_ context.Context, sql string, params ...string) ([]map[ rid := params[0] enc := base64.StdEncoding.EncodeToString([]byte(f.globals[`^mIrisRun("`+rid+`","out")`])) return []map[string]string{{"out": enc}}, nil + case strings.Contains(sql, "Abort"): + // Mirror the runner: terminate the run's recorded pid iff it is set and + // the run has not completed ("done"); return "" when nothing is live. + rid := params[0] + pid := f.globals[`^mIrisRun("`+rid+`","pid")`] + if pid == "" || f.globals[`^mIrisRun("`+rid+`","done")`] == "1" { + return []map[string]string{{"pid": ""}}, nil + } + f.globals[`^mIrisRun("`+rid+`","aborted")`] = "1" + return []map[string]string{{"pid": pid}}, nil case strings.Contains(sql, "SetGlobal"): f.globals[params[0]] = params[1] return []map[string]string{{"ok": "1"}}, nil @@ -185,6 +195,38 @@ func TestLoad_MapsDotMToIntDocname(t *testing.T) { } } +// TestRemoteAbort_NoLiveRunReturnsEmpty proves abort is honest: with no run +// recorded under the prefix, nothing is killed (parity with m-ydb's "no jobs +// matched" — a synchronous run has already returned by the time abort fires). +func TestRemoteAbort_NoLiveRunReturnsEmpty(t *testing.T) { + tr := New(newFakeAPI()) + killed, err := tr.Abort(context.Background(), "zzt-nothing") + if err != nil { + t.Fatalf("Abort: %v", err) + } + if len(killed) != 0 { + t.Errorf("killed = %v, want none", killed) + } +} + +// TestRemoteAbort_LiveRunReturnsKilledPid proves abort terminates a run whose +// process is still registered (pid set, "done" unset) and reports the pid. +func TestRemoteAbort_LiveRunReturnsKilledPid(t *testing.T) { + api := newFakeAPI() + api.globals[`^mIrisRun("zzt9","pid")`] = "173733" // a run in flight + tr := New(api) + killed, err := tr.Abort(context.Background(), "zzt9") + if err != nil { + t.Fatalf("Abort: %v", err) + } + if len(killed) != 1 || killed[0] != "173733" { + t.Errorf("killed = %v, want [173733]", killed) + } + if api.globals[`^mIrisRun("zzt9","aborted")`] != "1" { + t.Error("abort did not mark the run aborted") + } +} + // TestRemoteData_SetGetRoundTrip proves data.set/get ride the same substrate. func TestRemoteData_SetGetRoundTrip(t *testing.T) { api := newFakeAPI() diff --git a/internal/remote/runner/m.iris.Runner.cls b/internal/remote/runner/m.iris.Runner.cls index c43ec27..21a0cbe 100644 --- a/internal/remote/runner/m.iris.Runner.cls +++ b/internal/remote/runner/m.iris.Runner.cls @@ -50,6 +50,9 @@ ClassMethod RunRef(rid As %String, ref As %String, args As %String = "") As %Int if '..authorized() quit 7 kill ^mIrisRun(rid) set ^mIrisRun(rid,"status")=0 + // Record THIS process so a concurrent `exec abort --prefix rid` can stop the + // run while it is in flight; "done" (set last) tells abort the run finished. + set ^mIrisRun(rid,"pid")=$job try { do start^mIrisIO(rid) do @ref @@ -73,6 +76,7 @@ ClassMethod Eval(rid As %String, cmd As %String) As %Integer [ SqlName = "Eval", if '..authorized() quit 7 kill ^mIrisRun(rid) set ^mIrisRun(rid,"status")=0 + set ^mIrisRun(rid,"pid")=$job try { do start^mIrisIO(rid) xecute cmd @@ -124,6 +128,23 @@ ClassMethod KillGlobal(ref As %String) As %Integer [ SqlName = "KillGlobal", Sql quit 1 } +/// Abort stops a run still in flight under run id `rid`: the run records its +/// process ($job) in ^mIrisRun(rid,"pid") and sets "done" last, so a run is +/// abortable iff a pid is recorded, "done" is not yet set, and that process is +/// still live (^$JOB(pid) defined). It never terminates the caller's own process. +/// Returns the terminated pid, or "" when nothing was live to stop (the common +/// case over the synchronous Atelier path — parity with m-ydb's "no jobs matched"). +/// SQL: SELECT m_iris.Abort(?) +ClassMethod Abort(rid As %String) As %String [ SqlName = "Abort", SqlProc ] +{ + if '..authorized() quit "DENIED" + set pid=$get(^mIrisRun(rid,"pid")) + if pid=""!$data(^mIrisRun(rid,"done"))!(pid=$job)!'$data(^$JOB(pid)) quit "" + do $system.Process.Terminate(pid,2) + set ^mIrisRun(rid,"aborted")=1 + quit pid +} + /// Ping returns $zversion — a cheap readiness/version probe through the same /// substrate, so `doctor` can prove the action/query privilege, not just /// reachability (risks C3, C7). From b4915e95275b6d75d82ac381f3ba91d15d556ceb Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Fri, 12 Jun 2026 15:55:08 -0400 Subject: [PATCH 17/24] =?UTF-8?q?feat(transport):=20docker/local=20`iris?= =?UTF-8?q?=20session`=20transport=20=E2=80=94=20closes=20M3=20(conformanc?= =?UTF-8?q?e=2016/16=20remote+docker)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds internal/session implementing mdriver.Transport + Abort over `iris session -U ` (docker wraps it in `docker exec -i `; local runs it on the host). Unlike the remote/Atelier transport, a session writes to the principal device, so device `W` output is captured directly — none of the mIrisIO redirect / result-global recovery machinery is needed (that is a remote-only problem). A transport.go selector (newExecTransport / execTransport interface = the SDK Transport plus the driver-local Abort) picks remote vs session, so the exec axis is transport-agnostic. lifecycle (status/up/down/restart/wait) and meta doctor now dispatch to a session probe too: docker `up` = `docker start` + wait-healthy, `down` = `docker stop`; remote/local `down` detaches. Capture protocol (live-proven): an `iris session` runs each stdin line independently at the prompt, so a $ZTRAP set on a prior line does NOT fire — fault capture uses a single-line TRY/CATCH bracketed by @@MIRIS-BEGIN@@/@@MIRIS-RESULT@@|<§7 frame>. Load pipes source into the container (or a host temp file) then $SYSTEM.OBJ.Load(path,"ck"); ReadGlobal base64-encodes the value so control bytes survive the terminal capture. New config: --container / M_IRIS_CONTAINER, --iris-instance / M_IRIS_IRIS_INSTANCE (default IRIS). Also de-flakes TestRemoteAbort_RealEngine: pre-deploy the runner on both transports (a ReadGlobal calls ensureRunner) before the timing-sensitive pid poll, removing the concurrent PUT+compile race. Gates: go test -race ./... + gofmt + vet green; conformance 16/16 on BOTH remote and docker; make test-it green vs m-test-iris — new TestSessionAxis_RealEngine exercises health/version, load(.m->.int+compile)->run with capture, eval clean+fault(§7), data set/get through a control byte, and abort of a live `hang`. M3 -> done. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 5 +- docs/m-iris-tracker.md | 5 +- docs/memory/MEMORY.md | 2 +- docs/memory/m-iris-exec-axis-t0a5.md | 31 ++ doctor.go | 51 ++- exec.go | 36 +-- internal/config/config.go | 15 +- internal/remote/integration_test.go | 16 +- internal/session/integration_test.go | 156 ++++++++++ internal/session/session.go | 446 +++++++++++++++++++++++++++ internal/session/session_test.go | 196 ++++++++++++ lifecycle.go | 112 +++++-- transport.go | 57 ++++ 13 files changed, 1074 insertions(+), 54 deletions(-) create mode 100644 internal/session/integration_test.go create mode 100644 internal/session/session.go create mode 100644 internal/session/session_test.go create mode 100644 transport.go diff --git a/Makefile b/Makefile index 9b41ee1..1563b71 100644 --- a/Makefile +++ b/Makefile @@ -40,10 +40,13 @@ IRIS_BASE_URL ?= http://localhost:52774/api/atelier/v1/ IRIS_NAMESPACE ?= USER IRIS_USER ?= _SYSTEM IRIS_PASSWORD ?= testsys +IRIS_CONTAINER ?= m-test-iris +IRIS_INSTANCE ?= IRIS test-it: M_IRIS_IT=1 M_IRIS_BASE_URL=$(IRIS_BASE_URL) M_IRIS_NAMESPACE=$(IRIS_NAMESPACE) \ M_IRIS_USER=$(IRIS_USER) M_IRIS_PASSWORD=$(IRIS_PASSWORD) \ - go test $(GOFLAGS) -count=1 -run RealEngine . ./internal/remote/ -v + M_IRIS_CONTAINER=$(IRIS_CONTAINER) M_IRIS_IRIS_INSTANCE=$(IRIS_INSTANCE) \ + go test $(GOFLAGS) -count=1 -run RealEngine . ./internal/remote/ ./internal/session/ -v tidy: go mod tidy diff --git a/docs/m-iris-tracker.md b/docs/m-iris-tracker.md index ba975cd..d729864 100644 --- a/docs/m-iris-tracker.md +++ b/docs/m-iris-tracker.md @@ -6,14 +6,15 @@ Protocol). Update the active row here, in this repo, every increment. The shared cross-repo roll-up, synced at milestone boundaries — do not edit it from a driver spike. Status: ☐ todo · ◐ in progress · ☑ done. -Pinned: `m-driver-sdk v0.2.0`. Branch: `m-iris-driver`. Transports: local·docker·remote. +Pinned: `m-driver-sdk v0.2.0`. Branch: `m-iris-driver`. Transports: remote (Atelier, live) · docker (`iris session`, live) · local (`iris session`, wired/host-unvalidated). | M | Axis | Status | Notes | |---|---|---|---| | M0 | scaffold + SDK seam + `meta` | ☑ | honest caps golden; rename irissync→m-iris | | M1 | lifecycle + health + doctor | ☑ | remote/attach; real-IRIS 2026.1 validated | | M2 | sync (8 verbs) | ☑ | diff/rm/push --from/bare-name filter; real-IRIS green (404 + PutDoc bugs fixed) | -| M3 | exec (load/run/eval/abort) + engineError | ◐ | **exec `load`/`run`/`eval` WIRED over the remote runner (2026-06-12)** — `exec.go` + `execCmd` mounted in `CLI`; caps advertises `exec`; IRIS fault→§7 engineError; the SDK reference `Client` now drives a live VistA over the seam. Device `W` output is now CAPTURED (see device-capture note below). **T0a.5 driver-path PROVEN on foia** (`v pkg install/verify/uninstall --engine iris` — all 3 M0a invariants green, deterministic). **`exec abort` WIRED + live-proven (2026-06-12)** — runner records each run's `$job` in `^mIrisRun(rid,"pid")` (set right after status, cleared-by-"done"); new `m_iris.Abort(rid)` SqlProc terminates a live, not-`done` pid via `$system.Process.Terminate(pid,2)` guarded by `^$JOB(pid)` liveness + self-check, returns the pid (`"DENIED"`=role-fail, `""`=nothing live); `remote.Transport.Abort`→`exec abort --prefix`; caps Exec now `[load,run,eval,abort]`. `TestRemoteAbort_RealEngine` aborts a live `hang 30` on m-test-iris (reports pid; second abort finds nothing). Conformance **16/16 remote**. **Remaining for M3 ☑: local/docker `iris session` transports** (docker-exec into m-test-iris is reachable here — validatable). | +| M3 | exec (load/run/eval/abort) + engineError | ☑ | **DONE 2026-06-12 — abort + the docker `iris session` transport landed, conformance 16/16 on BOTH remote and docker.** `internal/session` implements `mdriver.Transport`+`Abort` over `iris session -U ` (docker wraps it in `docker exec -i `); device `W` captured directly off the principal device (no mIrisIO redirect — that is a remote/Atelier-only problem). A `transport.go` selector (`newExecTransport`/`execTransport` iface) picks remote vs session; exec/lifecycle/doctor are now transport-agnostic (status/up/down/restart/wait + doctor dispatch to a session probe; docker `up`=`docker start`+wait, `down`=`docker stop`). Live-validated `TestSessionAxis_RealEngine` on m-test-iris: health/version, load(.m→.int+compile)→run w/ capture, eval clean+fault(§7), data set/get through a control byte, abort of a live `hang`. Capture protocol (live-proven): single-line TRY/CATCH bracketed by `@@MIRIS-BEGIN@@`/`@@MIRIS-RESULT@@\|` (interactive `iris session` does NOT honor a `$ZTRAP` set on a prior stdin line). `local` is the same code path minus the docker wrap (no host IRIS here to live-validate). Earlier exec detail (kept) ↓ | +| M3a | exec `load`/`run`/`eval` over the remote runner | ☑ | **WIRED 2026-06-12** — `exec.go` + `execCmd` mounted in `CLI`; caps advertises `exec`; IRIS fault→§7 engineError; the SDK reference `Client` now drives a live VistA over the seam. Device `W` output CAPTURED (see device-capture note below). **T0a.5 driver-path PROVEN on foia** (`v pkg install/verify/uninstall --engine iris` — all 3 M0a invariants green, deterministic). — `exec.go` + `execCmd` mounted in `CLI`; caps advertises `exec`; IRIS fault→§7 engineError; the SDK reference `Client` now drives a live VistA over the seam. Device `W` output is now CAPTURED (see device-capture note below). **T0a.5 driver-path PROVEN on foia** (`v pkg install/verify/uninstall --engine iris` — all 3 M0a invariants green, deterministic). **`exec abort` WIRED + live-proven (2026-06-12)** — runner records each run's `$job` in `^mIrisRun(rid,"pid")` (set right after status, cleared-by-"done"); new `m_iris.Abort(rid)` SqlProc terminates a live, not-`done` pid via `$system.Process.Terminate(pid,2)` guarded by `^$JOB(pid)` liveness + self-check, returns the pid (`"DENIED"`=role-fail, `""`=nothing live); `remote.Transport.Abort`→`exec abort --prefix`; caps Exec now `[load,run,eval,abort]`. `TestRemoteAbort_RealEngine` aborts a live `hang 30` on m-test-iris (reports pid; second abort finds nothing). Conformance **16/16 remote**. (Session transport since landed — see M3 row above.) | | M4 | data (get/set/kill/query/export/import) | ☐ | remote via runner, SQL-wrapped | | M5 | cover (%Monitor.LineByLine → LCOV) | ☐ | port mcov.FromMonitor | | M6 | admin (backup/restore/check/journal) | ☐ | | diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index d67c198..f02c4ab 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -12,4 +12,4 @@ for how m-iris stays in lockstep with m-ydb via `m-driver-sdk`. - [m-iris driver M0–M2 + remote spike](m-iris-driver-m0-spike.md) — IRIS driver (D1), branch `m-iris-driver`. M0+M1+M2 done — sync axis 8-verb parity (diff/rm/push --from/bare-name filter); real-IRIS-2026.1 validated (404 + PutDoc result.status bugs fixed, 8c2f010). Atelier-SQL runner substrate gated. Next M3 exec. Pins m-driver-sdk v0.2.0. - [m-iris public facade](m-iris-public-facade.md) — NEW `irisdriver.New(Config)→mdriver.Transport` for m-cli/VistaEngine (peer of m-ydb's ydbdriver). Live-validated vs m-test-iris (banner returned). NOTE: the old "IRIS Exec does NOT capture device `W`" rule is **superseded** by [[m-iris-exec-axis-t0a5]] — the runner now redirects device output into `^mIrisRun(rid,"out")`. -- [exec axis + T0a.5 driver-path](m-iris-exec-axis-t0a5.md) — **M0a T0a.5 PROVEN on IRIS foia (2026-06-12)**: wired `exec load/run/eval` over the remote runner (closes the no-op gap), `.m`→`.int` + UDL `ROUTINE … [Type=INT]` header (#16021), device-`W` capture via `%Device.ReDirectIO`+companion `mIrisIO.int`, and the KIDS-over-Atelier device-corruption recovery (200+empty-body → `done`-gated global recovery, Base64 `GetOut`, retry on fresh connection). `v pkg install/verify/uninstall --engine iris` green. SDK still v0.2.0. +- [exec axis + T0a.5 driver-path](m-iris-exec-axis-t0a5.md) — **M3 DONE (2026-06-12)**: full exec axis (load/run/eval/abort) over BOTH remote (Atelier runner) and the new docker/local `iris session` transport; conformance 16/16 on remote AND docker. Has: the remote runner device-`W` capture + KIDS-over-Atelier corruption recovery; the session single-line TRY/CATCH `@@MIRIS-BEGIN/RESULT@@` capture protocol (`$ZTRAP` won't cross stdin-prompt lines); `exec abort` (`$job`+`^$JOB`+`Process.Terminate`); `transport.go` selector. T0a.5 (`v pkg … --engine iris`) proven on foia. SDK still v0.2.0. diff --git a/docs/memory/m-iris-exec-axis-t0a5.md b/docs/memory/m-iris-exec-axis-t0a5.md index 43da4e5..9fc02b3 100644 --- a/docs/memory/m-iris-exec-axis-t0a5.md +++ b/docs/memory/m-iris-exec-axis-t0a5.md @@ -33,6 +33,37 @@ argument exec`, exit 2, which the Client swallowed). Fixed, all in m-iris: with `ROUTINE `, and `.cls`). **The fake tier missed this — only the live engine caught it** (encoded back into the fake's `PutDoc` as a #16021 guard). +## M3 DONE — docker/local `iris session` transport (added 2026-06-12) +`internal/session` implements `mdriver.Transport`+`Abort` over `iris session + -U ` (docker = `docker exec -i iris session …`; local = +bare, host-unvalidated — no host IRIS here). **Device `W` captured DIRECTLY off the +principal device** — none of the remote/Atelier mIrisIO-redirect or +global-recovery machinery is needed (that whole mess is a remote-only problem). +**`transport.go` selector** `newExecTransport(conn)→execTransport` (=`mdriver.Transport` ++`Abort`) picks remote vs session; exec/lifecycle/doctor are now transport-agnostic +(status/up/down/restart/wait + doctor dispatch to a session probe; docker `up`= +`docker start`+wait-healthy, `down`=`docker stop`; remote/local `down`=detach no-op). +New config: `--container`/`M_IRIS_CONTAINER`, `--iris-instance`/`M_IRIS_IRIS_INSTANCE` +(default `IRIS`). **Conformance 16/16 on BOTH remote AND docker.** Live tier +`TestSessionAxis_RealEngine` (gated `M_IRIS_IT=1`+`M_IRIS_CONTAINER`; `make test-it` +now runs `. ./internal/remote/ ./internal/session/`). + +**Capture protocol (live-proven, the crux):** an `iris session` reading stdin runs +each line independently at the `USER>` prompt — so a **`$ZTRAP` set on a prior line +does NOT fire** (the error prints inline and the next line still runs). Fault capture +therefore uses a **single-line TRY/CATCH** in the same physical line as the code: +`write "@@MIRIS-BEGIN@@",! set st=0,em="" try { xecute mcmd } catch ex { set st=5,em= +ex.Name_"|"_$piece(ex.Location,"^",2)_"|"_… } write "@@MIRIS-RESULT@@",st,"|",em,! halt`. +Parser takes text between BEGIN and RESULT as stdout, then `|`. +User cmd carried as an escaped ObjectScript string literal (double the `"`) and +`xecute`'d (so a syntax error is caught, not a wrapper crash). Load = pipe source into +the container (`docker exec -i … sh -c 'cat > /tmp/X.int'`) / host temp file for local, +then `$system.OBJ.Load(path,"ck")` (compile fault → LoadResult.EngineError). ReadGlobal +Base64-encodes the value (control-byte safe, like remote GetOut). +**De-flake note:** `TestRemoteAbort_RealEngine` was flaky ("run never registered a +pid") under concurrent runner PUT+compile from two transports — fixed by pre-deploying +the runner on both (a `ReadGlobal` calls `ensureRunner`) before the timing-sensitive poll. + ## exec abort (added 2026-06-12 — closes the last exec verb) Abort over the synchronous Atelier path needs a live target, so the runner now records its OWN `$job` into `^mIrisRun(rid,"pid")` (set right after `status`, and diff --git a/doctor.go b/doctor.go index b56f5c2..e8f27e2 100644 --- a/doctor.go +++ b/doctor.go @@ -11,6 +11,7 @@ import ( "github.com/vista-cloud-dev/m-iris/clikit" "github.com/vista-cloud-dev/m-iris/internal/atelier" "github.com/vista-cloud-dev/m-iris/internal/config" + "github.com/vista-cloud-dev/m-iris/internal/session" ) // minIRISYear is the oldest IRIS major (release year) m-iris supports. @@ -30,16 +31,60 @@ type ( ) func (doctorCmd) Run(cc *clikit.Context, conn *config.Conn) error { - if err := remoteOnly(conn); err != nil { - return err + ctx := context.Background() + var res doctorResult + var exit int + switch conn.Transport { + case "", mdriver.TransportRemote: + res, exit = runDoctorRemote(ctx, conn) + case mdriver.TransportDocker, mdriver.TransportLocal: + res, exit = runDoctorSession(ctx, conn) + default: + return remoteOnly(conn) } - res, exit := runDoctorRemote(context.Background(), conn) // doctor's payload is a full report even on a non-zero outcome, so emit the // data envelope with the resolved exit (0 / 5 / 6) — the envelope's exit then // matches the process exit (driver-contract §2). return cc.ResultExit(res, exit, func() { renderDoctor(cc, res) }) } +// runDoctorSession runs the local/docker check matrix: config inputs present, the +// container is running (docker), `iris session` answers with a supported version, +// and the target namespace is usable. It returns the typed result + exit (0/5/6). +func runDoctorSession(ctx context.Context, conn *config.Conn) (doctorResult, int) { + res := doctorResult{Transport: conn.Transport} + add := func(name string, ok bool, detail, fix string) { + res.Checks = append(res.Checks, doctorCheck{Name: name, OK: ok, Detail: detail, Fix: fix}) + } + if err := validateSession(conn); err != nil { + add("config", false, err.Error(), "set --namespace (and --container for docker), or M_IRIS_* env") + return finalize(res), clikit.ExitRuntime + } + add("config", true, "namespace + transport inputs present", "") + + sess := session.New(conn.Session(), nil) + h, err := sess.Health(ctx) + switch { + case err != nil: + add("reachable", false, "iris session did not launch: "+err.Error(), + "check the container is running (docker) or iris is on PATH (local)") + skipDownstream(add, "unreachable") + return finalize(res), clikit.ExitUnreachable + case !h.Healthy: + add("reachable", false, "iris session launched but did not answer", + "check the IRIS instance "+conn.IrisInstance+" is started and the namespace exists") + skipDownstream(add, "no answer") + return finalize(res), clikit.ExitUnreachable + } + add("reachable", true, "iris session answered", "") + add("auth", true, "session runs as the instance user", "") + add(versionOK(h.Version)) + add("namespace", true, "running in namespace "+conn.Namespace, "") + add("query-privilege", true, "session executes ObjectScript directly (no SQL gateway)", "") + add("license", true, "not probed (session has a console connection)", "") + return finalize(res), exitFor(res) +} + // runDoctorRemote runs the remote (Atelier) check matrix and returns the typed // result plus the exit code (0 / 6 / 5). func runDoctorRemote(ctx context.Context, conn *config.Conn) (doctorResult, int) { diff --git a/exec.go b/exec.go index 5b0d975..db82dec 100644 --- a/exec.go +++ b/exec.go @@ -8,17 +8,16 @@ import ( mdriver "github.com/vista-cloud-dev/m-driver-sdk" "github.com/vista-cloud-dev/m-iris/clikit" "github.com/vista-cloud-dev/m-iris/internal/config" - "github.com/vista-cloud-dev/m-iris/internal/remote" ) -// execCmd is the exec axis (driver-contract §5.3) over the IRIS `remote` -// transport: run M against the attached namespace through the m.iris.Runner -// substrate. load PUT+compiles routine source over Atelier (neutral .m source is -// staged as a classic .int routine); run executes an entryref and eval one -// command, each through the runner's fault trap that surfaces a structured -// engineError (§7) on a runtime fault. All three ride internal/remote.Transport -// — the substrate the remote spike de-risked; this axis wires it to the CLI so -// the SDK reference Client (and therefore `v pkg install`) can drive a lifecycle. +// execCmd is the exec axis (driver-contract §5.3): run M against the attached +// namespace. It is written against execTransport (newExecTransport selects the +// strategy by --transport), so every verb works on remote (Atelier PUT + +// m.iris.Runner SQL substrate, output recovered from a result global) and on +// local/docker (`iris session`, device output captured directly). load stages + +// compiles routine source (neutral .m → .int); run/eval execute an entryref / one +// command under a fault trap that surfaces a structured engineError (§7); abort +// stops a run still in flight under its ephemeral --prefix. type execCmd struct { Load execLoadCmd `cmd:"" name:"load" help:"Stage routine source into the namespace (Atelier PUT) and compile it; neutral .m → .int. Compile faults surface as engineError."` Run execRunCmd `cmd:"" name:"run" help:"Run an entryref (LABEL^ROUTINE) through the runner; args → the formallist. Faults surface as engineError."` @@ -36,19 +35,6 @@ type execLoadResult struct { Compiled bool `json:"compiled"` } -// remoteTransport builds the remote (Atelier REST + runner) transport for the -// exec axis, after refusing the not-yet-wired local/docker transports. -func remoteTransport(conn *config.Conn) (*remote.Transport, error) { - if err := remoteOnly(conn); err != nil { - return nil, err - } - client, err := remoteClient(conn) - if err != nil { - return nil, err - } - return remote.New(client), nil -} - // --- load -------------------------------------------------------------------- type execLoadCmd struct { @@ -60,7 +46,7 @@ func (c *execLoadCmd) Run(cc *clikit.Context, conn *config.Conn) error { if len(c.Paths) == 0 { return clikit.Fail(clikit.ExitUsage, "NO_SOURCE", "exec load needs ", "") } - tr, err := remoteTransport(conn) + tr, err := newExecTransport(conn) if err != nil { return err } @@ -105,7 +91,7 @@ func (c *execAbortCmd) Run(cc *clikit.Context, conn *config.Conn) error { if c.Prefix == "" { return clikit.Fail(clikit.ExitUsage, "NO_PREFIX", "exec abort needs --prefix", "") } - tr, err := remoteTransport(conn) + tr, err := newExecTransport(conn) if err != nil { return err } @@ -138,7 +124,7 @@ func (c *execEvalCmd) Run(cc *clikit.Context, conn *config.Conn) error { // fault becomes an ok=false envelope with engineError (exit 5); otherwise // {stdout, status}. func runExec(cc *clikit.Context, conn *config.Conn, req mdriver.ExecRequest) error { - tr, err := remoteTransport(conn) + tr, err := newExecTransport(conn) if err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index 86c1da3..0f34bfd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,14 +15,17 @@ import ( "github.com/vista-cloud-dev/m-iris/internal/atelier" "github.com/vista-cloud-dev/m-iris/internal/mirror" + "github.com/vista-cloud-dev/m-iris/internal/session" ) // Conn is embedded in the root CLI struct, so its fields are global flags on // every subcommand, and bound (via kong.Bind) so command Run methods receive a // *Conn. Flags win over env; defaults fill the rest. type Conn struct { - Transport string `env:"M_IRIS_TRANSPORT" enum:"local,docker,remote" default:"remote" help:"Engine transport: local | docker | remote (Atelier REST). Only remote is wired today."` + Transport string `env:"M_IRIS_TRANSPORT" enum:"local,docker,remote" default:"remote" help:"Engine transport: local | docker (iris session) | remote (Atelier REST)."` BaseURL string `name:"base-url" env:"M_IRIS_BASE_URL" help:"Atelier base URL, e.g. https://host:52773/api/atelier/v1/" placeholder:"URL"` + Container string `env:"M_IRIS_CONTAINER" help:"docker transport: container name to exec into (iris session runs inside it)." placeholder:"NAME"` + IrisInstance string `name:"iris-instance" env:"M_IRIS_IRIS_INSTANCE" default:"IRIS" help:"local/docker transport: IRIS instance name for 'iris session '." placeholder:"NAME"` Instance string `env:"M_IRIS_INSTANCE" help:"Instance label used in the mirror path." placeholder:"NAME"` Namespace string `env:"M_IRIS_NAMESPACE" help:"IRIS namespace to liberate." placeholder:"NS"` Mirror string `env:"M_IRIS_MIRROR" default:".m-cache" help:"Mirror root directory."` @@ -107,6 +110,16 @@ func readSecret(inline, file string) (string, error) { return strings.TrimSpace(string(b)), nil } +// Session maps the connection onto the local/docker session transport config. +func (c *Conn) Session() session.Config { + return session.Config{ + Transport: c.Transport, + Container: c.Container, + Instance: c.IrisInstance, + Namespace: c.Namespace, + } +} + // Layout builds the mirror layout from the connection flags. func (c *Conn) Layout() mirror.Layout { return mirror.Layout{Root: c.Mirror, Instance: c.Instance, Namespace: c.Namespace} diff --git a/internal/remote/integration_test.go b/internal/remote/integration_test.go index ed122a8..3f29fcd 100644 --- a/internal/remote/integration_test.go +++ b/internal/remote/integration_test.go @@ -191,6 +191,17 @@ func TestRemoteAbort_RealEngine(t *testing.T) { _, _ = ctlClient.Query(ctx, "SELECT m_iris.KillGlobal(?)", `^mIrisRun("`+rid+`")`) }) + // Pre-deploy the runner on BOTH transports before the timing-sensitive part: + // ReadGlobal calls ensureRunner, so the later `hang` Eval registers its pid + // immediately instead of racing a concurrent PUT+compile of the runner class. + pidRef := `^mIrisRun("` + rid + `","pid")` + if _, err := runTr.ReadGlobal(ctx, mdriver.GlobalRef{Ref: pidRef}); err != nil { + t.Fatalf("warm up run transport: %v", err) + } + if _, err := ctlTr.ReadGlobal(ctx, mdriver.GlobalRef{Ref: pidRef}); err != nil { + t.Fatalf("warm up ctl transport: %v", err) + } + // Launch a long-running run; it sets ^mIrisRun(rid,"pid")=$job then hangs. done := make(chan struct{}) go func() { @@ -198,11 +209,10 @@ func TestRemoteAbort_RealEngine(t *testing.T) { _, _ = runTr.Exec(ctx, mdriver.ExecRequest{Command: "hang 30", Prefix: rid}) }() - // Wait until the run has registered its process (deploys the runner on the - // ctl transport's first read; idempotent with the run transport's deploy). + // Wait until the run has registered its process. var pid string for i := 0; i < 100; i++ { - node, rerr := ctlTr.ReadGlobal(ctx, mdriver.GlobalRef{Ref: `^mIrisRun("` + rid + `","pid")`}) + node, rerr := ctlTr.ReadGlobal(ctx, mdriver.GlobalRef{Ref: pidRef}) if rerr == nil && node.Value != "" { pid = node.Value break diff --git a/internal/session/integration_test.go b/internal/session/integration_test.go new file mode 100644 index 0000000..daa8b29 --- /dev/null +++ b/internal/session/integration_test.go @@ -0,0 +1,156 @@ +package session + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + mdriver "github.com/vista-cloud-dev/m-driver-sdk" +) + +// TestSessionAxis_RealEngine drives the local/docker `iris session` transport end +// to end against a real IRIS container: health/version, load (.m → .int + compile) +// + run with device-output capture, eval (clean + a fault → §7 EngineError), a +// data set/get round-trip through a control byte, and abort of a run still in +// flight. The fake-run unit tests above run every commit; this tier needs a live +// container, gated on M_IRIS_IT=1 + M_IRIS_CONTAINER. +// +// M_IRIS_IT=1 M_IRIS_CONTAINER=m-test-iris M_IRIS_NAMESPACE=USER \ +// go test ./internal/session/ -run TestSessionAxis_RealEngine -v +func TestSessionAxis_RealEngine(t *testing.T) { + if os.Getenv("M_IRIS_IT") != "1" { + t.Skip("set M_IRIS_IT=1 (+ M_IRIS_CONTAINER / M_IRIS_NAMESPACE) to run the session real-engine tier") + } + container := os.Getenv("M_IRIS_CONTAINER") + if container == "" { + t.Skip("set M_IRIS_CONTAINER to the IRIS docker container name") + } + cfg := Config{ + Transport: mdriver.TransportDocker, + Container: container, + Instance: envOr("M_IRIS_IRIS_INSTANCE", "IRIS"), + Namespace: envOr("M_IRIS_NAMESPACE", "USER"), + } + s := New(cfg, nil) + ctx := context.Background() + + // 1. Health carries the IRIS version. + h, err := s.Health(ctx) + if err != nil { + t.Fatalf("Health: %v", err) + } + if !h.Healthy || h.Version == "" { + t.Fatalf("health = %+v, want healthy with a version", h) + } + t.Logf("IRIS version %s", h.Version) + + // 2. Load a neutral .m routine (staged as .int + compiled) and run it; its + // WRITE output is captured directly off the session's principal device. + dir := t.TempDir() + src := filepath.Join(dir, "ZZSESIT.m") + body := "ZZSESIT ;session IT — safe to delete\nEN ;\n W \"<>ok=1\",!\n Q\n" + if err := os.WriteFile(src, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = s.Exec(ctx, mdriver.ExecRequest{Command: `do $system.OBJ.Delete("ZZSESIT.int")`}) + _ = s.SetGlobal(ctx, `^mSesIT`, "") // touch, then kill below + _, _ = s.Exec(ctx, mdriver.ExecRequest{Command: `kill ^mSesIT,^mIrisRun("zzsesabort")`}) + }) + + lr, err := s.Load(ctx, mdriver.LoadRequest{Paths: []string{src}}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if lr.EngineError != nil { + t.Fatalf("Load compile fault: %+v", lr.EngineError) + } + if len(lr.Loaded) != 1 || lr.Loaded[0] != "ZZSESIT.int" { + t.Fatalf("Loaded = %v, want [ZZSESIT.int]", lr.Loaded) + } + run, err := s.Exec(ctx, mdriver.ExecRequest{EntryRef: "EN^ZZSESIT"}) + if err != nil { + t.Fatalf("Exec run: %v", err) + } + if run.EngineError != nil { + t.Fatalf("run fault: %+v", run.EngineError) + } + if !strings.Contains(run.Stdout, "<>ok=1") { + t.Fatalf("run Stdout = %q, want it to contain <>ok=1", run.Stdout) + } + + // 3. eval — clean + a deliberate fault → structured EngineError, not a Go error. + ev, err := s.Exec(ctx, mdriver.ExecRequest{Command: `write "sum=",6*7`}) + if err != nil { + t.Fatalf("Exec eval: %v", err) + } + if ev.Stdout != "sum=42" { + t.Errorf("eval Stdout = %q, want sum=42", ev.Stdout) + } + fa, err := s.Exec(ctx, mdriver.ExecRequest{Command: `set x=^mNoSuchSes(1)`}) + if err != nil { + t.Fatalf("fault eval returned a Go error: %v", err) + } + if fa.EngineError == nil || fa.EngineError.Mnemonic == "" { + t.Fatalf("expected a structured EngineError, got %+v", fa) + } + + // 4. data set/get round-trips through a control byte (base64-safe capture). + want := "round\x01trip" + if err := s.SetGlobal(ctx, `^mSesIT("k")`, want); err != nil { + t.Fatalf("SetGlobal: %v", err) + } + node, err := s.ReadGlobal(ctx, mdriver.GlobalRef{Ref: `^mSesIT("k")`}) + if err != nil { + t.Fatalf("ReadGlobal: %v", err) + } + if node.Value != want { + t.Errorf("read-back = %q, want %q", node.Value, want) + } + + // 5. abort a run still in flight (a prefixed `hang` registers its $job; abort + // terminates it). Two sessions: one hangs, one aborts. + const rid = "zzsesabort" + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = s.Exec(ctx, mdriver.ExecRequest{Command: "hang 30", Prefix: rid}) + }() + var pid string + for i := 0; i < 100; i++ { + n, rerr := s.ReadGlobal(ctx, mdriver.GlobalRef{Ref: `^mIrisRun("` + rid + `","pid")`}) + if rerr == nil && n.Value != "" { + pid = n.Value + break + } + time.Sleep(100 * time.Millisecond) + } + if pid == "" { + t.Fatal("prefixed run never registered a pid") + } + killed, err := s.Abort(ctx, rid) + if err != nil { + t.Fatalf("Abort: %v", err) + } + if len(killed) != 1 || killed[0] != pid { + t.Fatalf("killed = %v, want [%s]", killed, pid) + } + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("aborted run did not return after termination") + } + if again, _ := s.Abort(ctx, rid); len(again) != 0 { + t.Errorf("second abort killed = %v, want none", again) + } +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..25c955c --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,446 @@ +// Package session is the IRIS `local` and `docker` transport: it drives an IRIS +// namespace by piping ObjectScript into an `iris session -U ` +// process and capturing the principal device's output directly. Unlike the +// `remote` (Atelier) transport — which has no run endpoint and must route every +// operation through the m.iris.Runner SQL class and recover output from a result +// global — a session transport writes to stdout, so device `W` output is the +// command's output with no redirection machinery. The two transports differ only +// in reach: docker wraps the same `iris session` argv in `docker exec -i +// `; local runs it on the host (iris on PATH). +package session + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + mdriver "github.com/vista-cloud-dev/m-driver-sdk" +) + +// Session implements mdriver.Transport for local + docker, plus the driver-local +// Abort verb (exec.abort is not a neutral Transport method). +var _ mdriver.Transport = (*Session)(nil) + +// Markers bracket the real result inside `iris session`'s noisy stdout (banner + +// `USER>` prompts). Everything between beginMark and endMark is the command's +// captured device output; endMark is followed by "|<§7 frame>". +const ( + beginMark = "@@MIRIS-BEGIN@@" + endMark = "@@MIRIS-RESULT@@" +) + +// Config is the resolved connection for a session transport. Container is used +// only by docker; Instance is the IRIS instance name inside the host/container +// (defaults to "IRIS"); Namespace is the `-U` target. +type Config struct { + Transport string // "local" | "docker" + Container string // docker: container name to exec into + Instance string // IRIS instance name (default "IRIS") + Namespace string // -U namespace +} + +// CmdOutput is one OS command's captured result (the in-strategy seam). +type CmdOutput struct { + Stdout string + Stderr string + Code int +} + +// runFunc runs a prepared argv feeding stdin, returning captured output. It is +// the lower-level seam inside the session strategy; tests inject a fake to assert +// argv construction without a real engine, production uses osRun. +type runFunc func(ctx context.Context, argv []string, stdin string) (CmdOutput, error) + +// Session is the local/docker Transport. +type Session struct { + cfg Config + run runFunc +} + +// New builds a session transport. A nil run uses the real OS runner. +func New(cfg Config, run runFunc) *Session { + if cfg.Instance == "" { + cfg.Instance = "IRIS" + } + if run == nil { + run = osRun + } + return &Session{cfg: cfg, run: run} +} + +func (s *Session) isDocker() bool { return s.cfg.Transport == mdriver.TransportDocker } + +// IsDocker reports whether this is the docker transport. +func (s *Session) IsDocker() bool { return s.isDocker() } + +// Container is the docker container name (empty for local). +func (s *Session) Container() string { return s.cfg.Container } + +// Docker runs a host `docker` command (start/stop/inspect) to manage the +// container itself — distinct from `docker exec`, which the verbs use to run +// inside it. Docker-transport only. +func (s *Session) Docker(ctx context.Context, args ...string) (CmdOutput, error) { + return s.run(ctx, append([]string{"docker"}, args...), "") +} + +// sessionArgv is the `iris session` invocation that reads ObjectScript on stdin. +func (s *Session) sessionArgv() []string { + return []string{"iris", "session", s.cfg.Instance, "-U", s.cfg.Namespace} +} + +// wrap adapts an argv to the active transport: `docker exec -i ` for +// docker, the bare argv for local. +func (s *Session) wrap(argv []string) []string { + if s.isDocker() { + return append([]string{"docker", "exec", "-i", s.cfg.Container}, argv...) + } + return argv +} + +// irisString renders s as an ObjectScript string literal (doubling embedded +// quotes), so a user command/ref/value is carried safely into the session script. +func irisString(s string) string { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` +} + +// execScript builds the single-execution stdin for an ExecRequest. The user code +// runs inside a one-line TRY/CATCH (interactive `iris session` does NOT honor a +// $ZTRAP set on a prior stdin line — each line executes independently at the +// prompt — so the trap must be in the same line as the code). On a fault the +// catch emits status 5 + the §7 frame "mnemonic|routine|line|text". +func (s *Session) execScript(req mdriver.ExecRequest) (string, error) { + var setup, inner string + switch { + case req.EntryRef != "": + setup = "set mref=" + irisString(req.EntryRef) + "\n" + inner = "do @mref" + case req.Command != "": + setup = "set mcmd=" + irisString(req.Command) + "\n" + inner = "xecute mcmd" + default: + return "", fmt.Errorf("session: exec needs an entryref or a command") + } + if req.Prefix != "" { + // Register this process so `exec abort --prefix` can stop the run in + // flight; "done" is set after the run completes (see runScript on Abort). + setup += "set ^mIrisRun(" + irisString(req.Prefix) + `,"pid")=$job` + "\n" + } + return setup + s.trapLine(inner), nil +} + +// trapLine wraps inner in the begin/result-marker + one-line TRY/CATCH and halts. +func (s *Session) trapLine(inner string) string { + return `write "` + beginMark + `",! set st=0,em="" ` + + `try { ` + inner + ` } ` + + `catch ex { set st=5,em=ex.Name_"|"_$piece(ex.Location,"^",2)_"|"_$piece($piece(ex.Location,"^",1),"+",2)_"|"_ex.DisplayString() } ` + + `write "` + endMark + `",st,"|",em,! halt` + "\n" +} + +// runScript pipes a prepared session script and parses the bracketed result. +func (s *Session) runScript(ctx context.Context, script string) (captured string, status int, eng *mdriver.EngineError, err error) { + out, rerr := s.run(ctx, s.wrap(s.sessionArgv()), script) + if rerr != nil { + return "", 0, nil, rerr + } + captured, status, eng, ok := parseSession(out.Stdout) + if !ok { + return "", 0, nil, fmt.Errorf("session: could not parse iris session output (stderr: %q)", strings.TrimSpace(out.Stderr)) + } + return captured, status, eng, nil +} + +// parseSession extracts the captured output and "|" tail from a +// session's stdout, discarding the surrounding banner/prompt noise. +func parseSession(stdout string) (captured string, status int, eng *mdriver.EngineError, ok bool) { + bi := strings.Index(stdout, beginMark) + if bi < 0 { + return "", 0, nil, false + } + rest := stdout[bi+len(beginMark):] + rest = strings.TrimPrefix(rest, "\r") + rest = strings.TrimPrefix(rest, "\n") + ki := strings.Index(rest, endMark) + if ki < 0 { + return "", 0, nil, false + } + captured = rest[:ki] + line := rest[ki+len(endMark):] + if nl := strings.IndexAny(line, "\r\n"); nl >= 0 { + line = line[:nl] + } + stStr, frame := line, "" + if p := strings.IndexByte(line, '|'); p >= 0 { + stStr, frame = line[:p], line[p+1:] + } + status, _ = strconv.Atoi(strings.TrimSpace(stStr)) + if status == 5 { + eng = parseFrame(frame) + } + return captured, status, eng, true +} + +// parseFrame parses a "mnemonic|routine|line|text" §7 error frame. +func parseFrame(raw string) *mdriver.EngineError { + parts := strings.SplitN(raw, "|", 4) + eng := &mdriver.EngineError{} + if len(parts) > 0 { + eng.Mnemonic = parts[0] + } + if len(parts) > 1 { + eng.Routine = parts[1] + } + if len(parts) > 2 { + eng.Line, _ = strconv.Atoi(parts[2]) + } + if len(parts) > 3 { + eng.Text = parts[3] + } + return eng +} + +// Exec runs an entryref or evaluates a command in the namespace, capturing device +// output. A fault is data (ExecResult.EngineError, §7), not a Go error. +func (s *Session) Exec(ctx context.Context, req mdriver.ExecRequest) (mdriver.ExecResult, error) { + script, err := s.execScript(req) + if err != nil { + return mdriver.ExecResult{}, err + } + captured, status, eng, err := s.runScript(ctx, script) + if err != nil { + return mdriver.ExecResult{}, err + } + return mdriver.ExecResult{Stdout: captured, Status: status, EngineError: eng}, nil +} + +var versionRe = regexp.MustCompile(`\d{4}\.\d+`) + +// Health probes readiness + version in one round-trip: `write $zversion`. The +// session is healthy when it answers with a non-empty version banner. +func (s *Session) Health(ctx context.Context) (mdriver.Health, error) { + captured, _, eng, err := s.runScript(ctx, s.trapLine("write $zversion")) + if err != nil { + return mdriver.Health{Running: false, Healthy: false}, err + } + ready := eng == nil && strings.TrimSpace(captured) != "" + return mdriver.Health{Running: ready, Healthy: ready, Version: versionRe.FindString(captured)}, nil +} + +// Version returns the IRIS release (e.g. "2026.1") for status/info/doctor. +func (s *Session) Version(ctx context.Context) (string, error) { + h, err := s.Health(ctx) + if err != nil { + return "", err + } + return h.Version, nil +} + +// SetGlobal sets @ref=value via an indirect set (data.set / fixture seeding). +func (s *Session) SetGlobal(ctx context.Context, ref, value string) error { + cmd := "set @(" + irisString(ref) + ")=" + irisString(value) + _, status, eng, err := s.runScript(ctx, s.trapLine(cmd)) + if err != nil { + return err + } + if eng != nil { + return fmt.Errorf("session: set %s failed: %s %s", ref, eng.Mnemonic, eng.Text) + } + if status != 0 { + return fmt.Errorf("session: set %s returned status %d", ref, status) + } + return nil +} + +// ReadGlobal reads $get(@ref) (data.get). The value is Base64-encoded in the +// session (like the remote runner's GetOut) so control bytes survive the noisy +// terminal capture intact. +func (s *Session) ReadGlobal(ctx context.Context, req mdriver.GlobalRef) (mdriver.GlobalNode, error) { + cmd := "write $system.Encryption.Base64Encode($get(@(" + irisString(req.Ref) + ")))" + captured, _, eng, err := s.runScript(ctx, s.trapLine(cmd)) + if err != nil { + return mdriver.GlobalNode{}, err + } + if eng != nil { + return mdriver.GlobalNode{}, fmt.Errorf("session: read %s failed: %s %s", req.Ref, eng.Mnemonic, eng.Text) + } + b64 := strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == ' ' || r == '\t' { + return -1 + } + return r + }, captured) + if b64 == "" { + return mdriver.GlobalNode{Ref: req.Ref}, nil + } + raw, derr := base64.StdEncoding.DecodeString(b64) + if derr != nil { + return mdriver.GlobalNode{}, fmt.Errorf("session: decode %s: %w", req.Ref, derr) + } + return mdriver.GlobalNode{Ref: req.Ref, Value: string(raw)}, nil +} + +// Load stages routine source into the namespace and compiles it (exec.load). The +// neutral ".m" source maps to a ".int" docname with the UDL ROUTINE header (the +// same rules as the remote transport), is placed in the engine's filesystem +// (piped into the container for docker, written to a host temp file for local), +// then loaded with $SYSTEM.OBJ.Load(path,"ck"). A compile fault is returned as a +// LoadResult.EngineError, not a Go error. +func (s *Session) Load(ctx context.Context, req mdriver.LoadRequest) (mdriver.LoadResult, error) { + files, err := expandPaths(req.Paths) + if err != nil { + return mdriver.LoadResult{}, err + } + var loaded []string + for _, f := range files { + content, rerr := os.ReadFile(f) + if rerr != nil { + return mdriver.LoadResult{}, rerr + } + name := req.Prefix + irisDocname(filepath.Base(f)) + body := irisRoutineLines(name, splitLines(string(content))) + path, serr := s.stage(ctx, name, strings.Join(body, "\n")+"\n") + if serr != nil { + return mdriver.LoadResult{}, serr + } + inner := "set sc=$system.OBJ.Load(" + irisString(path) + `,"ck") ` + + `if 'sc { set st=5,em="||0|"_$system.Status.GetErrorText(sc) }` + _, _, eng, lerr := s.runScript(ctx, s.trapLine(inner)) + if lerr != nil { + return mdriver.LoadResult{}, lerr + } + if eng != nil { + return mdriver.LoadResult{Loaded: loaded, EngineError: eng}, nil + } + loaded = append(loaded, name) + } + return mdriver.LoadResult{Loaded: loaded}, nil +} + +// stage places source content where the engine can load it and returns the path +// the session should pass to $SYSTEM.OBJ.Load: piped into the container under +// docker, or a host temp file for local. +func (s *Session) stage(ctx context.Context, name, content string) (string, error) { + path := "/tmp/" + name + if s.isDocker() { + argv := []string{"docker", "exec", "-i", s.cfg.Container, "sh", "-c", "cat > " + path} + if _, err := s.run(ctx, argv, content); err != nil { + return "", fmt.Errorf("session: stage %s into container: %w", name, err) + } + return path, nil + } + tmp, err := os.CreateTemp("", "miris-*-"+name) + if err != nil { + return "", err + } + defer tmp.Close() + if _, err := tmp.WriteString(content); err != nil { + return "", err + } + return tmp.Name(), nil +} + +// Abort stops a run still in flight under the ephemeral prefix (exec.abort). The +// prefixed exec registered its process in ^mIrisRun(rid,"pid"); this terminates a +// live, not-"done" process (^$JOB liveness, never self) and writes the pid back. +// Driver-local, not a neutral Transport method (like m-ydb's Session.Abort). +func (s *Session) Abort(ctx context.Context, prefix string) ([]string, error) { + rid := irisString(prefix) + inner := "set pid=$get(^mIrisRun(" + rid + `,"pid"))` + + ` if pid'=""&'$data(^mIrisRun(` + rid + `,"done"))&(pid'=$job)&$data(^$JOB(pid)) { do $system.Process.Terminate(pid,2) set ^mIrisRun(` + rid + `,"aborted")=1 write pid }` + captured, _, eng, err := s.runScript(ctx, s.trapLine(inner)) + if err != nil { + return nil, err + } + if eng != nil { + return nil, fmt.Errorf("session: abort %s failed: %s %s", prefix, eng.Mnemonic, eng.Text) + } + pid := strings.TrimSpace(captured) + if pid == "" { + return nil, nil + } + return []string{pid}, nil +} + +// --- shared file helpers (mirror internal/remote) ---------------------------- + +// irisDocname maps a routine-source basename to a valid IRIS docname: neutral +// ".m" → ".int" (classic MUMPS), other IRIS extensions pass through. +func irisDocname(base string) string { + if strings.EqualFold(filepath.Ext(base), ".m") { + return strings.TrimSuffix(base, filepath.Ext(base)) + ".int" + } + return base +} + +// irisRoutineLines prepends the UDL `ROUTINE [Type=…]` header IRIS requires +// for a routine doc (else #16021), unless one is already present or the type is +// not a routine. +func irisRoutineLines(docname string, lines []string) []string { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(docname), ".")) + switch ext { + case "int", "mac", "inc": + default: + return lines + } + if len(lines) > 0 && strings.HasPrefix(lines[0], "ROUTINE ") { + return lines + } + name := strings.TrimSuffix(filepath.Base(docname), filepath.Ext(docname)) + return append([]string{fmt.Sprintf("ROUTINE %s [Type=%s]", name, strings.ToUpper(ext))}, lines...) +} + +func splitLines(s string) []string { return strings.Split(strings.TrimRight(s, "\n"), "\n") } + +func expandPaths(paths []string) ([]string, error) { + var out []string + for _, p := range paths { + info, err := os.Stat(p) + if err != nil { + return nil, err + } + if !info.IsDir() { + out = append(out, p) + continue + } + entries, err := os.ReadDir(p) + if err != nil { + return nil, err + } + for _, e := range entries { + if !e.IsDir() { + out = append(out, filepath.Join(p, e.Name())) + } + } + } + return out, nil +} + +// osRun is the production runner: it executes argv feeding stdin. A non-zero exit +// is a CmdOutput code, not a Go error — only a failure to launch is an error. +func osRun(ctx context.Context, argv []string, stdin string) (CmdOutput, error) { + if len(argv) == 0 { + return CmdOutput{}, errors.New("session: empty argv") + } + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + code := 0 + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + code, err = ee.ExitCode(), nil + } + } + return CmdOutput{Stdout: out.String(), Stderr: errb.String(), Code: code}, err +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000..8d1fb06 --- /dev/null +++ b/internal/session/session_test.go @@ -0,0 +1,196 @@ +package session + +import ( + "context" + "encoding/base64" + "strconv" + "strings" + "testing" + + mdriver "github.com/vista-cloud-dev/m-driver-sdk" +) + +// fakeRun records the argv + stdin of the last session invocation and returns a +// scripted CmdOutput, so tests assert both how the `iris session` command is +// built (docker wrapping, namespace) and how its noisy stdout is parsed — +// without a real engine. +type fakeRun struct { + lastArgv []string + lastStdin string + fn func(stdin string) (CmdOutput, error) +} + +func (f *fakeRun) run(_ context.Context, argv []string, stdin string) (CmdOutput, error) { + f.lastArgv, f.lastStdin = argv, stdin + if f.fn != nil { + return f.fn(stdin) + } + return CmdOutput{}, nil +} + +// sessionStdout reproduces the shape `iris session` emits: banner + prompt noise, +// then the captured region between the begin marker and the result marker, then +// the trailing prompt — so the parser is tested against realistic noise. +func sessionStdout(captured string, status int, frame string) string { + return "\nNode: host, Instance: IRIS\n\nUSER>\n" + + beginMark + "\n" + captured + + endMark + strconv.Itoa(status) + "|" + frame + "\n\nUSER>\n" +} + +func dockerCfg() Config { + return Config{Transport: "docker", Container: "m-test-iris", Instance: "IRIS", Namespace: "USER"} +} + +func TestExec_Eval_CapturesStdoutAndWrapsDocker(t *testing.T) { + fr := &fakeRun{fn: func(string) (CmdOutput, error) { + return CmdOutput{Stdout: sessionStdout("hi=42\n", 0, "")}, nil + }} + s := New(dockerCfg(), fr.run) + + res, err := s.Exec(context.Background(), mdriver.ExecRequest{Command: "write \"hi=\",6*7,!"}) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if res.Status != 0 || res.EngineError != nil { + t.Errorf("res = %+v, want status 0 no engineError", res) + } + if res.Stdout != "hi=42\n" { + t.Errorf("Stdout = %q, want \"hi=42\\n\"", res.Stdout) + } + // docker wrapping + the iris session invocation into the right namespace. + got := strings.Join(fr.lastArgv, " ") + for _, want := range []string{"docker exec -i m-test-iris", "iris session IRIS -U USER"} { + if !strings.Contains(got, want) { + t.Errorf("argv %q missing %q", got, want) + } + } + // The user command is carried as an escaped string literal and xecute'd. + if !strings.Contains(fr.lastStdin, "xecute mcmd") { + t.Errorf("stdin did not xecute the eval command:\n%s", fr.lastStdin) + } +} + +func TestExec_Fault_BecomesEngineError(t *testing.T) { + fr := &fakeRun{fn: func(string) (CmdOutput, error) { + return CmdOutput{Stdout: sessionStdout("", 5, "|XLFISO|12|global undefined")}, nil + }} + s := New(dockerCfg(), fr.run) + + res, err := s.Exec(context.Background(), mdriver.ExecRequest{Command: "set x=^nope(1)"}) + if err != nil { + t.Fatalf("a fault must be data, not a Go error: %v", err) + } + if res.EngineError == nil { + t.Fatal("expected an EngineError") + } + if res.EngineError.Mnemonic != "" || res.EngineError.Routine != "XLFISO" || res.EngineError.Line != 12 { + t.Errorf("engineError = %+v, want XLFISO:12", res.EngineError) + } +} + +func TestExec_Run_BuildsEntryRefDo(t *testing.T) { + fr := &fakeRun{fn: func(string) (CmdOutput, error) { + return CmdOutput{Stdout: sessionStdout("<>ok\n", 0, "")}, nil + }} + s := New(dockerCfg(), fr.run) + if _, err := s.Exec(context.Background(), mdriver.ExecRequest{EntryRef: "EN^ZZSESX"}); err != nil { + t.Fatalf("Exec run: %v", err) + } + if !strings.Contains(fr.lastStdin, `set mref="EN^ZZSESX"`) || !strings.Contains(fr.lastStdin, "do @mref") { + t.Errorf("run stdin did not build the entryref do:\n%s", fr.lastStdin) + } +} + +func TestExec_Run_RecordsPidWhenPrefixed(t *testing.T) { + fr := &fakeRun{fn: func(string) (CmdOutput, error) { + return CmdOutput{Stdout: sessionStdout("", 0, "")}, nil + }} + s := New(dockerCfg(), fr.run) + if _, err := s.Exec(context.Background(), mdriver.ExecRequest{EntryRef: "LOOP^ZZ", Prefix: "zzt9"}); err != nil { + t.Fatalf("Exec: %v", err) + } + if !strings.Contains(fr.lastStdin, `^mIrisRun("zzt9","pid")=$job`) { + t.Errorf("prefixed run did not register its pid:\n%s", fr.lastStdin) + } +} + +func TestHealth_ParsesVersion(t *testing.T) { + fr := &fakeRun{fn: func(string) (CmdOutput, error) { + return CmdOutput{Stdout: sessionStdout("IRIS for UNIX (Ubuntu) 2026.1 (Build 234U)", 0, "")}, nil + }} + s := New(dockerCfg(), fr.run) + h, err := s.Health(context.Background()) + if err != nil { + t.Fatalf("Health: %v", err) + } + if !h.Running || !h.Healthy { + t.Errorf("health = %+v, want running+healthy", h) + } + if h.Version != "2026.1" { + t.Errorf("version = %q, want 2026.1", h.Version) + } +} + +func TestSetGlobal_LocalNoDockerWrap(t *testing.T) { + fr := &fakeRun{fn: func(string) (CmdOutput, error) { + return CmdOutput{Stdout: sessionStdout("", 0, "")}, nil + }} + s := New(Config{Transport: "local", Instance: "IRIS", Namespace: "USER"}, fr.run) + if err := s.SetGlobal(context.Background(), `^mFix("k")`, "hello"); err != nil { + t.Fatalf("SetGlobal: %v", err) + } + if fr.lastArgv[0] != "iris" { + t.Errorf("local argv should start with iris, got %v", fr.lastArgv) + } + if !strings.Contains(fr.lastStdin, `set @(`) { + t.Errorf("SetGlobal stdin did not build an indirect set:\n%s", fr.lastStdin) + } +} + +func TestReadGlobal_DecodesBase64(t *testing.T) { + val := "round\x01trip" // a control byte survives via base64 + enc := base64.StdEncoding.EncodeToString([]byte(val)) + fr := &fakeRun{fn: func(string) (CmdOutput, error) { + return CmdOutput{Stdout: sessionStdout(enc, 0, "")}, nil + }} + s := New(dockerCfg(), fr.run) + node, err := s.ReadGlobal(context.Background(), mdriver.GlobalRef{Ref: `^mFix("k")`}) + if err != nil { + t.Fatalf("ReadGlobal: %v", err) + } + if node.Value != val { + t.Errorf("value = %q, want %q", node.Value, val) + } +} + +func TestAbort_ReportsTerminatedPid(t *testing.T) { + fr := &fakeRun{fn: func(string) (CmdOutput, error) { + // The session abort eval writes the terminated pid into the captured region. + return CmdOutput{Stdout: sessionStdout("173733", 0, "")}, nil + }} + s := New(dockerCfg(), fr.run) + killed, err := s.Abort(context.Background(), "zzt9") + if err != nil { + t.Fatalf("Abort: %v", err) + } + if len(killed) != 1 || killed[0] != "173733" { + t.Errorf("killed = %v, want [173733]", killed) + } +} + +func TestAbort_NothingLiveReturnsEmpty(t *testing.T) { + fr := &fakeRun{fn: func(string) (CmdOutput, error) { + return CmdOutput{Stdout: sessionStdout("", 0, "")}, nil + }} + s := New(dockerCfg(), fr.run) + killed, err := s.Abort(context.Background(), "nothing") + if err != nil { + t.Fatalf("Abort: %v", err) + } + if len(killed) != 0 { + t.Errorf("killed = %v, want none", killed) + } +} + +// satisfies the SDK Transport seam. +var _ mdriver.Transport = (*Session)(nil) diff --git a/lifecycle.go b/lifecycle.go index 5754d05..e9ac7e0 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -9,6 +9,7 @@ import ( "github.com/vista-cloud-dev/m-iris/clikit" "github.com/vista-cloud-dev/m-iris/internal/atelier" "github.com/vista-cloud-dev/m-iris/internal/config" + "github.com/vista-cloud-dev/m-iris/internal/session" ) // lifecycleCmd is the lifecycle axis (driver-contract §5.1): manage the engine @@ -64,6 +65,51 @@ func remoteClient(conn *config.Conn) (*atelier.Client, error) { return c, nil } +// probe dispatches readiness probing to the active transport: the Atelier root +// for remote, an `iris session` health/version round-trip for local/docker. +func probe(ctx context.Context, conn *config.Conn) (lifecycleStatus, error) { + switch conn.Transport { + case "", mdriver.TransportRemote: + return probeRemote(ctx, conn) + case mdriver.TransportDocker, mdriver.TransportLocal: + if err := validateSession(conn); err != nil { + return lifecycleStatus{}, err + } + return probeSession(ctx, conn) + default: + return lifecycleStatus{}, remoteOnly(conn) + } +} + +// probeSession probes the local/docker engine by running a health/version +// round-trip through `iris session`. A launch failure (container down, iris not +// on PATH) is reported as not-running, not a Go error — parity with probeRemote's +// unreachable branch. +func probeSession(ctx context.Context, conn *config.Conn) (lifecycleStatus, error) { + sess := session.New(conn.Session(), nil) + st := lifecycleStatus{Transport: conn.Transport, Endpoint: sessionEndpoint(conn)} + start := time.Now() + h, err := sess.Health(ctx) + st.LatencyMs = time.Since(start).Milliseconds() + if err != nil { + st.Running, st.Healthy = false, false + return st, nil + } + st.Running, st.Healthy, st.Version = h.Running, h.Healthy, h.Version + if h.Version != "" { + st.Namespaces = []string{conn.Namespace} + } + return st, nil +} + +// sessionEndpoint is a human label for the session target. +func sessionEndpoint(conn *config.Conn) string { + if conn.Transport == mdriver.TransportDocker { + return "docker:" + conn.Container + "/" + conn.IrisInstance + "/" + conn.Namespace + } + return "local:" + conn.IrisInstance + "/" + conn.Namespace +} + // probeRemote probes the Atelier root and classifies the result: reachable+ok, // reachable-but-auth-failed (server answered 401/403), or unreachable. func probeRemote(ctx context.Context, conn *config.Conn) (lifecycleStatus, error) { @@ -99,10 +145,7 @@ type lifeStatusCmd struct { } func (c lifeStatusCmd) Run(cc *clikit.Context, conn *config.Conn) error { - if err := remoteOnly(conn); err != nil { - return err - } - st, err := probeRemote(context.Background(), conn) + st, err := probe(context.Background(), conn) if err != nil { return err } @@ -134,30 +177,66 @@ func (c lifeStatusCmd) Run(cc *clikit.Context, conn *config.Conn) error { type lifeUpCmd struct{} func (lifeUpCmd) Run(cc *clikit.Context, conn *config.Conn) error { - if err := remoteOnly(conn); err != nil { - return err - } - st, err := probeRemote(context.Background(), conn) + ctx := context.Background() + st, err := probe(ctx, conn) if err != nil { return err } + // docker: the container is ours to start — bring it up, then re-probe. + if !st.Running && conn.Transport == mdriver.TransportDocker { + sess := session.New(conn.Session(), nil) + if _, derr := sess.Docker(ctx, "start", conn.Container); derr != nil { + return runtimeErr(derr) + } + st, err = waitHealthy(ctx, conn, 30*time.Second) + if err != nil { + return err + } + } if !st.Running { return engineUnreachable("up: engine is not reachable to attach to") } - return cc.Result(lifeStateResult{State: "attached", Endpoint: conn.BaseURL}, func() { - fmt.Fprintln(cc.Stdout, cc.Success("attached to "+conn.BaseURL)) + return cc.Result(lifeStateResult{State: "attached", Endpoint: st.Endpoint}, func() { + fmt.Fprintln(cc.Stdout, cc.Success("attached to "+st.Endpoint)) }) } +// waitHealthy polls until the engine is healthy or the deadline elapses. +func waitHealthy(ctx context.Context, conn *config.Conn, d time.Duration) (lifecycleStatus, error) { + deadline := time.Now().Add(d) + var st lifecycleStatus + for { + var err error + st, err = probe(ctx, conn) + if err != nil { + return lifecycleStatus{}, err + } + if st.Healthy || !time.Now().Before(deadline) { + return st, nil + } + time.Sleep(100 * time.Millisecond) + } +} + type lifeDownCmd struct{} func (lifeDownCmd) Run(cc *clikit.Context, conn *config.Conn) error { - if err := remoteOnly(conn); err != nil { - return err + // docker: the container is ours — stop it. remote/local: not ours to stop, so + // down just detaches (the server / host install is left running). + if conn.Transport == mdriver.TransportDocker { + if err := validateSession(conn); err != nil { + return err + } + sess := session.New(conn.Session(), nil) + if _, err := sess.Docker(context.Background(), "stop", conn.Container); err != nil { + return runtimeErr(err) + } + return cc.Result(lifeStateResult{State: "stopped", Endpoint: sessionEndpoint(conn)}, func() { + fmt.Fprintln(cc.Stdout, cc.Success("stopped container "+conn.Container)) + }) } - // The remote server is not ours to stop; down is a no-op that just detaches. return cc.Result(lifeStateResult{State: "detached"}, func() { - fmt.Fprintln(cc.Stdout, "detached (remote engine left running)") + fmt.Fprintln(cc.Stdout, "detached (engine left running)") }) } @@ -174,15 +253,12 @@ type lifeWaitCmd struct { } func (c *lifeWaitCmd) Run(cc *clikit.Context, conn *config.Conn) error { - if err := remoteOnly(conn); err != nil { - return err - } deadline := time.Now().Add(time.Duration(c.Timeout) * time.Second) const poll = 100 * time.Millisecond var st lifecycleStatus for { var err error - st, err = probeRemote(context.Background(), conn) + st, err = probe(context.Background(), conn) if err != nil { return err } diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..8a95502 --- /dev/null +++ b/transport.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "fmt" + + mdriver "github.com/vista-cloud-dev/m-driver-sdk" + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/config" + "github.com/vista-cloud-dev/m-iris/internal/remote" + "github.com/vista-cloud-dev/m-iris/internal/session" +) + +// execTransport is the neutral verb-level Transport plus the driver-local Abort +// verb (exec.abort is not an SDK Transport method — both the remote and session +// strategies satisfy it, like m-ydb's Session.Abort). The exec axis is written +// against this interface so it is transport-agnostic. +type execTransport interface { + mdriver.Transport + Abort(ctx context.Context, prefix string) ([]string, error) +} + +// newExecTransport selects the transport strategy for the resolved connection: +// the remote (Atelier REST + runner) transport, or the local/docker `iris +// session` transport. It validates the inputs each strategy needs. +func newExecTransport(conn *config.Conn) (execTransport, error) { + switch conn.Transport { + case "", mdriver.TransportRemote: + client, err := remoteClient(conn) + if err != nil { + return nil, err + } + return remote.New(client), nil + case mdriver.TransportDocker, mdriver.TransportLocal: + if err := validateSession(conn); err != nil { + return nil, err + } + return session.New(conn.Session(), nil), nil + default: + return nil, clikit.Fail(clikit.ExitUsage, "BAD_TRANSPORT", + fmt.Sprintf("unknown transport %q", conn.Transport), "use local | docker | remote") + } +} + +// validateSession checks the inputs the local/docker session transport needs: a +// namespace always, and a container name for docker. +func validateSession(conn *config.Conn) error { + if conn.Namespace == "" { + return clikit.Fail(clikit.ExitUsage, "NO_NAMESPACE", + "the local/docker transport needs --namespace (the IRIS namespace to run in)", "") + } + if conn.Transport == mdriver.TransportDocker && conn.Container == "" { + return clikit.Fail(clikit.ExitUsage, "NO_CONTAINER", + "the docker transport needs --container (or M_IRIS_CONTAINER)", "") + } + return nil +} From 353d8b9a24b90e94f878f977ae9a893731cc74fd Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Fri, 12 Jun 2026 16:09:44 -0400 Subject: [PATCH 18/24] feat(data): M4 data axis get/set/kill/query on all transports (conformance 16/16 remote+docker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the data axis (driver-contract §5.4): get/set/kill/query globals for fixtures + namespace inspection. get=ReadGlobal, set=SetGlobal (existed); kill and query are new on both transports: - kill: runner m_iris.KillGlobal SqlProc (remote) / `kill @ref` (session). - query: a $query subtree walk. Containment is one expression — $name(@cur,$qlength(ref))=ref (collation makes the subtree contiguous, so the walk quits as soon as it leaves it). The @cur indirection is essential: $name operates on the variable unless its value is indirected into a reference first (unlike $qsubscript, which takes the string directly). --order forward/reverse, --depth (0 = whole subtree). Both transports return the SAME node-list wire format — Base64(ref)Base64(value) per line (base64 each field so control bytes survive) — so one parseNodes decodes either. The runner QueryGlobal SqlProc returns the string; the session walk writes each node to the principal device, captured between the markers. engineTransport (transport.go) now carries Abort + KillGlobal + QueryGlobal, so the data axis is transport-agnostic. caps Data:[get,set,kill,query] (export/import deferred to a follow-up — not advertised, honest-by-construction). Gates: go test -race ./... + gofmt + vet green; conformance 16/16 on BOTH remote and docker; make test-it green vs m-test-iris — new TestRemoteData_RealEngine + the session-axis query/kill block (seeded subtree queries back its contained nodes excluding a sibling; kill removes the subtree). M4 -> in progress (export/import remain). Co-Authored-By: Claude Opus 4.8 (1M context) --- data.go | 122 ++++++++++++++++++++++ docs/m-iris-tracker.md | 2 +- docs/memory/MEMORY.md | 1 + docs/memory/m-iris-data-axis.md | 55 ++++++++++ internal/driver/caps.go | 4 + internal/driver/testdata/caps.golden.json | 6 ++ internal/remote/integration_test.go | 51 +++++++++ internal/remote/remote.go | 52 +++++++++ internal/remote/remote_test.go | 62 +++++++++++ internal/remote/runner/m.iris.Runner.cls | 35 +++++++ internal/session/integration_test.go | 21 ++++ internal/session/session.go | 74 +++++++++++++ internal/session/session_test.go | 39 +++++++ main.go | 3 +- transport.go | 14 +-- 15 files changed, 533 insertions(+), 8 deletions(-) create mode 100644 data.go create mode 100644 docs/memory/m-iris-data-axis.md diff --git a/data.go b/data.go new file mode 100644 index 0000000..d8f6eee --- /dev/null +++ b/data.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "fmt" + + mdriver "github.com/vista-cloud-dev/m-driver-sdk" + "github.com/vista-cloud-dev/m-iris/clikit" + "github.com/vista-cloud-dev/m-iris/internal/config" +) + +// dataCmd is the data axis (driver-contract §5.4): globals for fixtures, seeding, +// and namespace inspection without dropping into a native session. get/set/kill +// address one node (set/kill accept a subtree); query walks the subtree rooted at +// a reference. Every verb rides engineTransport, so it works on remote (the +// role-gated runner SqlProcs) and on local/docker (`iris session`, indirect +// @ref). export/import (bulk %GO/%GI dumps) land in a follow-up slice. +type dataCmd struct { + Get dataGetCmd `cmd:"" name:"get" help:"Read one global node: {value}."` + Set dataSetCmd `cmd:"" name:"set" help:"Set one global node: {ok}."` + Kill dataKillCmd `cmd:"" name:"kill" help:"Kill a global node or subtree: {ok}."` + Query dataQueryCmd `cmd:"" name:"query" help:"Walk the subtree rooted at a reference: {nodes[]}."` +} + +type dataGetResult struct { + Value string `json:"value"` +} + +type dataOKResult struct { + OK bool `json:"ok"` +} + +type dataQueryResult struct { + Nodes []mdriver.GlobalNode `json:"nodes"` +} + +// --- get --------------------------------------------------------------------- + +type dataGetCmd struct { + Ref string `arg:"" help:"Global reference, e.g. ^DD(0) or ^XUSEC(\"name\")."` +} + +func (c *dataGetCmd) Run(cc *clikit.Context, conn *config.Conn) error { + tr, err := newExecTransport(conn) + if err != nil { + return err + } + node, err := tr.ReadGlobal(context.Background(), mdriver.GlobalRef{Ref: c.Ref}) + if err != nil { + return runtimeErr(err) + } + return cc.Result(dataGetResult{Value: node.Value}, func() { + fmt.Fprintln(cc.Stdout, node.Value) + }) +} + +// --- set --------------------------------------------------------------------- + +type dataSetCmd struct { + Ref string `arg:"" help:"Global reference to set."` + Value string `arg:"" help:"Value to store."` +} + +func (c *dataSetCmd) Run(cc *clikit.Context, conn *config.Conn) error { + tr, err := newExecTransport(conn) + if err != nil { + return err + } + if err := tr.SetGlobal(context.Background(), c.Ref, c.Value); err != nil { + return runtimeErr(err) + } + return cc.Result(dataOKResult{OK: true}, func() { + fmt.Fprintln(cc.Stdout, cc.Success("set "+c.Ref)) + }) +} + +// --- kill -------------------------------------------------------------------- + +type dataKillCmd struct { + Ref string `arg:"" help:"Global reference (node or subtree) to kill."` +} + +func (c *dataKillCmd) Run(cc *clikit.Context, conn *config.Conn) error { + tr, err := newExecTransport(conn) + if err != nil { + return err + } + if err := tr.KillGlobal(context.Background(), c.Ref); err != nil { + return runtimeErr(err) + } + return cc.Result(dataOKResult{OK: true}, func() { + fmt.Fprintln(cc.Stdout, cc.Success("killed "+c.Ref)) + }) +} + +// --- query ------------------------------------------------------------------- + +type dataQueryCmd struct { + Ref string `arg:"" help:"Root global reference to walk."` + Order string `default:"forward" enum:"forward,reverse" help:"Collation order to walk: forward | reverse."` + Depth int `default:"0" help:"Max subscript levels below the root to include (0 = the whole subtree)."` +} + +func (c *dataQueryCmd) Run(cc *clikit.Context, conn *config.Conn) error { + tr, err := newExecTransport(conn) + if err != nil { + return err + } + nodes, err := tr.QueryGlobal(context.Background(), c.Ref, c.Order, c.Depth) + if err != nil { + return runtimeErr(err) + } + if nodes == nil { + nodes = []mdriver.GlobalNode{} + } + return cc.Result(dataQueryResult{Nodes: nodes}, func() { + cc.Title(fmt.Sprintf("%d node(s) under %s", len(nodes), c.Ref)) + for _, n := range nodes { + fmt.Fprintln(cc.Stdout, n.Ref+" = "+n.Value) + } + }) +} diff --git a/docs/m-iris-tracker.md b/docs/m-iris-tracker.md index d729864..ea96c26 100644 --- a/docs/m-iris-tracker.md +++ b/docs/m-iris-tracker.md @@ -15,7 +15,7 @@ Pinned: `m-driver-sdk v0.2.0`. Branch: `m-iris-driver`. Transports: remote (Atel | M2 | sync (8 verbs) | ☑ | diff/rm/push --from/bare-name filter; real-IRIS green (404 + PutDoc bugs fixed) | | M3 | exec (load/run/eval/abort) + engineError | ☑ | **DONE 2026-06-12 — abort + the docker `iris session` transport landed, conformance 16/16 on BOTH remote and docker.** `internal/session` implements `mdriver.Transport`+`Abort` over `iris session -U ` (docker wraps it in `docker exec -i `); device `W` captured directly off the principal device (no mIrisIO redirect — that is a remote/Atelier-only problem). A `transport.go` selector (`newExecTransport`/`execTransport` iface) picks remote vs session; exec/lifecycle/doctor are now transport-agnostic (status/up/down/restart/wait + doctor dispatch to a session probe; docker `up`=`docker start`+wait, `down`=`docker stop`). Live-validated `TestSessionAxis_RealEngine` on m-test-iris: health/version, load(.m→.int+compile)→run w/ capture, eval clean+fault(§7), data set/get through a control byte, abort of a live `hang`. Capture protocol (live-proven): single-line TRY/CATCH bracketed by `@@MIRIS-BEGIN@@`/`@@MIRIS-RESULT@@\|` (interactive `iris session` does NOT honor a `$ZTRAP` set on a prior stdin line). `local` is the same code path minus the docker wrap (no host IRIS here to live-validate). Earlier exec detail (kept) ↓ | | M3a | exec `load`/`run`/`eval` over the remote runner | ☑ | **WIRED 2026-06-12** — `exec.go` + `execCmd` mounted in `CLI`; caps advertises `exec`; IRIS fault→§7 engineError; the SDK reference `Client` now drives a live VistA over the seam. Device `W` output CAPTURED (see device-capture note below). **T0a.5 driver-path PROVEN on foia** (`v pkg install/verify/uninstall --engine iris` — all 3 M0a invariants green, deterministic). — `exec.go` + `execCmd` mounted in `CLI`; caps advertises `exec`; IRIS fault→§7 engineError; the SDK reference `Client` now drives a live VistA over the seam. Device `W` output is now CAPTURED (see device-capture note below). **T0a.5 driver-path PROVEN on foia** (`v pkg install/verify/uninstall --engine iris` — all 3 M0a invariants green, deterministic). **`exec abort` WIRED + live-proven (2026-06-12)** — runner records each run's `$job` in `^mIrisRun(rid,"pid")` (set right after status, cleared-by-"done"); new `m_iris.Abort(rid)` SqlProc terminates a live, not-`done` pid via `$system.Process.Terminate(pid,2)` guarded by `^$JOB(pid)` liveness + self-check, returns the pid (`"DENIED"`=role-fail, `""`=nothing live); `remote.Transport.Abort`→`exec abort --prefix`; caps Exec now `[load,run,eval,abort]`. `TestRemoteAbort_RealEngine` aborts a live `hang 30` on m-test-iris (reports pid; second abort finds nothing). Conformance **16/16 remote**. (Session transport since landed — see M3 row above.) | -| M4 | data (get/set/kill/query/export/import) | ☐ | remote via runner, SQL-wrapped | +| M4 | data (get/set/kill/query/export/import) | ◐ | **get/set/kill/query WIRED on all transports + conformance 16/16 remote & docker (2026-06-12).** `data.go` axis (`dataCmd` mounted in `CLI`); caps `Data:[get,set,kill,query]`. get=`ReadGlobal`, set=`SetGlobal` (existed); kill=new `KillGlobal` (runner `m_iris.KillGlobal` SqlProc / session `kill @ref`); query=new subtree walk — runner `m_iris.QueryGlobal(ref,order,depth)` SqlProc / session inline `$query` walk, both returning a node list `Base64(ref)\tBase64(value)\n` per node (control-byte safe). **Subtree containment = `$name(@cur,bl)=ref`** (collation-contiguous → quit on leave); `--order forward/reverse`, `--depth` (0=whole subtree). `engineTransport` iface (transport.go) now carries Abort+KillGlobal+QueryGlobal. Live tests `TestRemoteData_RealEngine` + session axis query/kill block. **Remaining: `export`/`import`** (bulk %GO/%GI / `%Library.Global.Export`, server-side dump files — heaviest, deferred to its own slice). | | M5 | cover (%Monitor.LineByLine → LCOV) | ☐ | port mcov.FromMonitor | | M6 | admin (backup/restore/check/journal) | ☐ | | | M7 | native passthrough (iris/atelier/sql) | ☐ | | diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index f02c4ab..e7f8918 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -12,4 +12,5 @@ for how m-iris stays in lockstep with m-ydb via `m-driver-sdk`. - [m-iris driver M0–M2 + remote spike](m-iris-driver-m0-spike.md) — IRIS driver (D1), branch `m-iris-driver`. M0+M1+M2 done — sync axis 8-verb parity (diff/rm/push --from/bare-name filter); real-IRIS-2026.1 validated (404 + PutDoc result.status bugs fixed, 8c2f010). Atelier-SQL runner substrate gated. Next M3 exec. Pins m-driver-sdk v0.2.0. - [m-iris public facade](m-iris-public-facade.md) — NEW `irisdriver.New(Config)→mdriver.Transport` for m-cli/VistaEngine (peer of m-ydb's ydbdriver). Live-validated vs m-test-iris (banner returned). NOTE: the old "IRIS Exec does NOT capture device `W`" rule is **superseded** by [[m-iris-exec-axis-t0a5]] — the runner now redirects device output into `^mIrisRun(rid,"out")`. +- [data axis (M4 get/set/kill/query)](m-iris-data-axis.md) — **M4 get/set/kill/query DONE (2026-06-12)**, conformance 16/16 remote+docker; export/import still ☐. The crux: `$query` subtree walk with `$name(@cur,$qlength(ref))=ref` containment (the `@cur` indirection is the gotcha — `$qsubscript` takes the string directly, `$name` needs `@`); shared node-list wire format `Base64(ref)\tBase64(value)\n`. - [exec axis + T0a.5 driver-path](m-iris-exec-axis-t0a5.md) — **M3 DONE (2026-06-12)**: full exec axis (load/run/eval/abort) over BOTH remote (Atelier runner) and the new docker/local `iris session` transport; conformance 16/16 on remote AND docker. Has: the remote runner device-`W` capture + KIDS-over-Atelier corruption recovery; the session single-line TRY/CATCH `@@MIRIS-BEGIN/RESULT@@` capture protocol (`$ZTRAP` won't cross stdin-prompt lines); `exec abort` (`$job`+`^$JOB`+`Process.Terminate`); `transport.go` selector. T0a.5 (`v pkg … --engine iris`) proven on foia. SDK still v0.2.0. diff --git a/docs/memory/m-iris-data-axis.md b/docs/memory/m-iris-data-axis.md new file mode 100644 index 0000000..14c79ac --- /dev/null +++ b/docs/memory/m-iris-data-axis.md @@ -0,0 +1,55 @@ +--- +name: m-iris-data-axis +description: m-iris M4 data axis (get/set/kill/query) over both transports — the $query subtree-walk + the $name(@cur) containment gotcha, and the shared node-list wire format. +metadata: + type: project +--- + +**M4 data axis get/set/kill/query — DONE 2026-06-12 (export/import still ☐).** +`data.go` (`dataCmd`) mounts the axis; caps `Data:[get,set,kill,query]`. All verbs +ride `engineTransport` (transport.go: `mdriver.Transport` + driver-local `Abort`, +`KillGlobal`, `QueryGlobal`), so they work identically on remote (runner SqlProcs) +and local/docker (`iris session`). Conformance 16/16 on remote AND docker; live +tiers `TestRemoteData_RealEngine` + the session-axis query/kill block. See +[[m-iris-exec-axis-t0a5]] for the transport selector + session capture protocol. + +## The hard-won gotcha — `$name(cur)` vs `$name(@cur)` (cost ~5 live probes) +The subtree-query walk uses `$query` + a containment test. The containment is a +SINGLE expression: a node `cur` is in `ref`'s subtree iff +**`$name(@cur,$qlength(ref))=ref`** — `$name(glvn,depth)` truncates a reference to +its first `depth` subscripts. THE BUG: `$name(cur,…)` operates on the *variable* +`cur` (returns "cur"/garbage), so it must be **`$name(@cur,…)`** to indirect cur's +string value into a real reference first. (Inconsistent with `$qsubscript`, which +takes the string-reference DIRECTLY with no `@` — that asymmetry is what burned +time.) With the `@`, `$name(@cur,1)` on `^X(1,"y")` → `^X(1)`. Collation makes a +subtree contiguous in `$query` order, so the walk `quit`s as soon as containment +first fails. `bl=0` (bare `^X`) → `$name(@cur,0)` = the global name → whole-global +walk. Validated live: query `^mDTST(1)` returns only `^mDTST(1)`+`^mDTST(1,"x")`, +excludes `^mDTST(2)`. + +## Shared node-list wire format (both transports → one Go parser) +Query returns nodes as **`Base64(ref)Base64(value)` per node** (base64 each +field so control bytes survive; TAB/LF framing is plain text). Remote: runner +`m_iris.QueryGlobal(ref,order,depth)` SqlProc returns the whole string (read from the +action/query row). Session: the inline `$query` walk **writes each node to the +principal device** (captured between the `@@MIRIS-BEGIN@@`/`@@MIRIS-RESULT@@` markers +like any session command). `parseNodes` (duplicated in internal/remote + internal/ +session) splits lines → split on TAB → base64-decode each → `[]mdriver.GlobalNode`. + +## Session $query walk — interactive-mode shape +Piped `iris session` runs each stdin line independently at the prompt, BUT **locals +DO persist across lines** (set `qref/qdir/qbl` on one line, use on the next — works). +A `for { … }` BLOCK piped interactively did NOT iterate (only the pre-for base node +emitted) — so the session walk uses the **argumentless-body `for` form** +(`for set qcur=$query(@qcur,qdir) quit:… quit:… write: `) with the +RESULT marker on the NEXT line (so it isn't swept into the FOR body). depth filter is +a write postconditional `'((qd>0)&(($qlength(qcur)-qbl)>qd))` (fully parenthesized — M +is strict left-to-right, no operator precedence). kill = `kill @()`. + +## Remaining: export/import (deferred, its own slice) +`data export --to` / `data import ` → `{bytes}`/`{loaded}`. Heaviest: +server-side dump files (`%Library.Global.Export` / `%GO`/`%GI`), and for remote the +file lands on the SERVER (client can't easily retrieve) — design the +where-does-the-file-live semantics before wiring. NOT advertised in caps until wired +(honest-by-construction). **needs SDK:** none — these are driver-local CLI result +shapes (`{bytes}`/`{loaded}`), not Transport-seam types. diff --git a/internal/driver/caps.go b/internal/driver/caps.go index 41e7c5b..532550a 100644 --- a/internal/driver/caps.go +++ b/internal/driver/caps.go @@ -31,6 +31,10 @@ func CapsDoc() mdriver.Caps { // in flight under its ephemeral --prefix (the runner records each run's // $job and terminates a live, not-done process). Exec: []string{"load", "run", "eval", "abort"}, + // M4 — data (globals). get/set/kill/query are wired on every transport + // (runner SqlProcs on remote; indirect @ref + a $query walk on session). + // export/import (bulk %GO/%GI) land in a follow-up — not advertised yet. + Data: []string{"get", "set", "kill", "query"}, }, Features: mdriver.Features{ Remote: true, // IRIS reaches over Atelier REST diff --git a/internal/driver/testdata/caps.golden.json b/internal/driver/testdata/caps.golden.json index dfc8ae0..b404766 100644 --- a/internal/driver/testdata/caps.golden.json +++ b/internal/driver/testdata/caps.golden.json @@ -32,6 +32,12 @@ "eval", "abort" ], + "data": [ + "get", + "set", + "kill", + "query" + ], "meta": [ "caps", "version", diff --git a/internal/remote/integration_test.go b/internal/remote/integration_test.go index 3f29fcd..ec2bdc1 100644 --- a/internal/remote/integration_test.go +++ b/internal/remote/integration_test.go @@ -248,6 +248,57 @@ func TestRemoteAbort_RealEngine(t *testing.T) { } } +// TestRemoteData_RealEngine proves data.query/kill ride the runner on a real +// IRIS: a seeded subtree queries back exactly its contained nodes (a sibling is +// excluded by the $name containment walk), and kill removes the whole subtree. +// +// Gated identically to the spike (M_IRIS_IT=1 + M_IRIS_* connection env). +func TestRemoteData_RealEngine(t *testing.T) { + if os.Getenv("M_IRIS_IT") != "1" { + t.Skip("set M_IRIS_IT=1 (+ M_IRIS_* connection env) to run the real-engine data test") + } + client, err := atelier.New(atelier.Config{ + BaseURL: envOr("M_IRIS_BASE_URL", "http://localhost:52773/api/atelier/v1/"), + Namespace: envOr("M_IRIS_NAMESPACE", "USER"), + User: envOr("M_IRIS_USER", "_SYSTEM"), + Password: envOr("M_IRIS_PASSWORD", "SYS"), + Timeout: 30 * time.Second, + }) + if err != nil { + t.Fatalf("atelier client: %v", err) + } + tr := New(client) + ctx := context.Background() + t.Cleanup(func() { _ = tr.KillGlobal(ctx, `^mDataIT`) }) + + for ref, val := range map[string]string{ + `^mDataIT("a")`: "1", + `^mDataIT("a","sub")`: "2", + `^mDataIT("b")`: "3", + } { + if err := tr.SetGlobal(ctx, ref, val); err != nil { + t.Fatalf("SetGlobal %s: %v", ref, err) + } + } + q, err := tr.QueryGlobal(ctx, `^mDataIT("a")`, "forward", 0) + if err != nil { + t.Fatalf("QueryGlobal: %v", err) + } + if len(q) != 2 { + t.Fatalf("query ^mDataIT(\"a\") = %d nodes, want 2 (excl. \"b\"): %+v", len(q), q) + } + if err := tr.KillGlobal(ctx, `^mDataIT("a")`); err != nil { + t.Fatalf("KillGlobal: %v", err) + } + whole, err := tr.QueryGlobal(ctx, `^mDataIT`, "forward", 0) + if err != nil { + t.Fatalf("QueryGlobal whole: %v", err) + } + if len(whole) != 1 || whole[0].Ref != `^mDataIT("b")` { + t.Fatalf("post-kill whole = %+v, want only ^mDataIT(\"b\")", whole) + } +} + func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/internal/remote/remote.go b/internal/remote/remote.go index 9b89904..c0a8dca 100644 --- a/internal/remote/remote.go +++ b/internal/remote/remote.go @@ -259,6 +259,58 @@ func (t *Transport) SetGlobal(ctx context.Context, ref, value string) error { return nil } +// KillGlobal kills a global node / subtree via the runner (contract data.kill). +func (t *Transport) KillGlobal(ctx context.Context, ref string) error { + if err := t.ensureRunner(ctx); err != nil { + return err + } + if _, err := t.api.Query(ctx, "SELECT m_iris.KillGlobal(?) AS ok", ref); err != nil { + return err + } + return nil +} + +// QueryGlobal walks the subtree rooted at ref and returns its contained nodes +// (contract data.query). The runner returns a node list — one line per node, +// "Base64(ref)Base64(value)" — which parseNodes decodes. order is +// "forward"/"reverse"; depth>0 caps levels below ref (0 = the whole subtree). +func (t *Transport) QueryGlobal(ctx context.Context, ref, order string, depth int) ([]mdriver.GlobalNode, error) { + if err := t.ensureRunner(ctx); err != nil { + return nil, err + } + rows, err := t.api.Query(ctx, "SELECT m_iris.QueryGlobal(?,?,?) AS nodes", ref, order, strconv.Itoa(depth)) + if err != nil { + return nil, err + } + return parseNodes(firstCol(rows, "nodes")) +} + +// parseNodes decodes a runner/session node list ("Base64(ref)Base64(value)" +// per line) into flat GlobalNodes. +func parseNodes(raw string) ([]mdriver.GlobalNode, error) { + var nodes []mdriver.GlobalNode + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimRight(line, "\r") + if line == "" { + continue + } + tab := strings.IndexByte(line, '\t') + if tab < 0 { + continue + } + ref, err := base64.StdEncoding.DecodeString(line[:tab]) + if err != nil { + return nil, fmt.Errorf("remote: decode query ref: %w", err) + } + val, err := base64.StdEncoding.DecodeString(line[tab+1:]) + if err != nil { + return nil, fmt.Errorf("remote: decode query value: %w", err) + } + nodes = append(nodes, mdriver.GlobalNode{Ref: string(ref), Value: string(val)}) + } + return nodes, nil +} + // getOut reads the captured result-global text for a run, Base64-encoded by the // runner so control bytes (a KIDS install's ANSI/terminal output) survive the // action/query JSON transport — a raw read truncates at the first non-text byte, diff --git a/internal/remote/remote_test.go b/internal/remote/remote_test.go index edb9f8d..7d5d880 100644 --- a/internal/remote/remote_test.go +++ b/internal/remote/remote_test.go @@ -49,6 +49,17 @@ func (f *fakeAPI) PutDoc(_ context.Context, name string, content []string) (*ate func (f *fakeAPI) CloseIdleConnections() {} +// inSubtree models the runner's $name(@cur,bl)=ref containment: a node is in the +// subtree of ref iff ref is ref itself or a parent reference. Approximated for the +// fake by matching ref's leading subscripts (strip the closing paren so +// ^X("a") contains ^X("a","sub")). +func inSubtree(node, ref string) bool { + if node == ref { + return true + } + return strings.HasPrefix(node, strings.TrimSuffix(ref, ")")) +} + func ext(name string) string { if i := strings.LastIndex(name, "."); i >= 0 { return strings.ToLower(name[i+1:]) @@ -96,6 +107,27 @@ func (f *fakeAPI) Query(_ context.Context, sql string, params ...string) ([]map[ case strings.Contains(sql, "SetGlobal"): f.globals[params[0]] = params[1] return []map[string]string{{"ok": "1"}}, nil + case strings.Contains(sql, "KillGlobal"): + // `kill @ref` removes the node and its whole subtree. + for ref := range f.globals { + if inSubtree(ref, params[0]) { + delete(f.globals, ref) + } + } + return []map[string]string{{"ok": "1"}}, nil + case strings.Contains(sql, "QueryGlobal"): + // Model the runner's node list: every stored global in the query ref's + // subtree, as Base64(ref)Base64(value) lines. + var b strings.Builder + for ref, val := range f.globals { + if inSubtree(ref, params[0]) { + b.WriteString(base64.StdEncoding.EncodeToString([]byte(ref))) + b.WriteByte('\t') + b.WriteString(base64.StdEncoding.EncodeToString([]byte(val))) + b.WriteByte('\n') + } + } + return []map[string]string{{"nodes": b.String()}}, nil case strings.Contains(sql, "GetGlobal"): return []map[string]string{{"value": f.globals[params[0]]}}, nil case strings.Contains(sql, "SELECT 1"): @@ -246,6 +278,36 @@ func TestRemoteData_SetGetRoundTrip(t *testing.T) { } } +// TestRemoteData_KillAndQuery proves data.kill removes a node and data.query +// returns the subtree's nodes decoded from the runner's Base64 node list. +func TestRemoteData_KillAndQuery(t *testing.T) { + api := newFakeAPI() + tr := New(api) + ctx := context.Background() + for ref, val := range map[string]string{ + `^mFix("a")`: "1", + `^mFix("a","sub")`: "2", + `^mOther("z")`: "9", + } { + if err := tr.SetGlobal(ctx, ref, val); err != nil { + t.Fatalf("SetGlobal %s: %v", ref, err) + } + } + nodes, err := tr.QueryGlobal(ctx, `^mFix("a")`, "forward", 0) + if err != nil { + t.Fatalf("QueryGlobal: %v", err) + } + if len(nodes) != 2 { + t.Fatalf("query returned %d nodes, want 2 (the ^mFix(\"a\") subtree only): %+v", len(nodes), nodes) + } + if err := tr.KillGlobal(ctx, `^mFix("a")`); err != nil { + t.Fatalf("KillGlobal: %v", err) + } + if n, _ := tr.QueryGlobal(ctx, `^mFix("a")`, "forward", 0); len(n) != 0 { + t.Errorf("post-kill query returned %d nodes, want 0 (subtree killed): %+v", len(n), n) + } +} + // TestRemoteHealth_ProbesQueryPrivilege proves Health asserts the action/query // privilege (SELECT 1), not just TCP reachability. func TestRemoteHealth_ProbesQueryPrivilege(t *testing.T) { diff --git a/internal/remote/runner/m.iris.Runner.cls b/internal/remote/runner/m.iris.Runner.cls index 21a0cbe..07c2543 100644 --- a/internal/remote/runner/m.iris.Runner.cls +++ b/internal/remote/runner/m.iris.Runner.cls @@ -145,6 +145,41 @@ ClassMethod Abort(rid As %String) As %String [ SqlName = "Abort", SqlProc ] quit pid } +/// QueryGlobal walks the subtree rooted at `ref` in collation order ($query) and +/// returns its contained nodes (those whose first $qlength(ref) subscripts match +/// ref — checked with $name(@cur,bl)=ref) as a node list: one line per node, +/// "Base64(ref)Base64(value)". Base64 keeps refs/values that hold control +/// bytes intact across the action/query JSON; the LF/TAB framing is plain text. +/// order="reverse" walks backward; depth>0 caps the levels below ref (0 = +/// unlimited); max bounds the node count. The base node is included when it holds +/// a value. (contract data.query) +/// SQL: SELECT m_iris.QueryGlobal(?,?,?) +ClassMethod QueryGlobal(ref As %String,order As %String = "forward",depth As %Integer = 0,max As %Integer = 100000) As %String [ SqlName = "QueryGlobal", SqlProc ] +{ + if '..authorized() quit "" + set dir=$select(order="reverse":-1,1:1) + set bl=$qlength(ref) + set out="",n=0 + if $data(@ref)#10 set out=..node(ref),n=1 + set cur=ref + for { + set cur=$query(@cur,dir) + if cur="" quit + if $name(@cur,bl)'=ref quit + if (depth>0)&(($qlength(cur)-bl)>depth) continue + set out=out_..node(cur) + set n=n+1 + if n>=max quit + } + quit out +} + +/// node serializes one reference as "Base64(ref)Base64(value)". +ClassMethod node(ref As %String) As %String [ Private ] +{ + quit $system.Encryption.Base64Encode(ref)_$char(9)_$system.Encryption.Base64Encode($get(@ref))_$char(10) +} + /// Ping returns $zversion — a cheap readiness/version probe through the same /// substrate, so `doctor` can prove the action/query privilege, not just /// reachability (risks C3, C7). diff --git a/internal/session/integration_test.go b/internal/session/integration_test.go index daa8b29..adf191e 100644 --- a/internal/session/integration_test.go +++ b/internal/session/integration_test.go @@ -111,6 +111,27 @@ func TestSessionAxis_RealEngine(t *testing.T) { t.Errorf("read-back = %q, want %q", node.Value, want) } + // 4b. query walks the subtree (and excludes a sibling); kill removes it. + if err := s.SetGlobal(ctx, `^mSesIT("k","sub")`, "deep"); err != nil { + t.Fatalf("SetGlobal sub: %v", err) + } + if err := s.SetGlobal(ctx, `^mSesIT("z")`, "sibling"); err != nil { + t.Fatalf("SetGlobal sibling: %v", err) + } + q, err := s.QueryGlobal(ctx, `^mSesIT("k")`, "forward", 0) + if err != nil { + t.Fatalf("QueryGlobal: %v", err) + } + if len(q) != 2 { + t.Fatalf("query ^mSesIT(\"k\") returned %d nodes, want 2 (excl. the \"z\" sibling): %+v", len(q), q) + } + if err := s.KillGlobal(ctx, `^mSesIT("k")`); err != nil { + t.Fatalf("KillGlobal: %v", err) + } + if after, _ := s.QueryGlobal(ctx, `^mSesIT("k")`, "forward", 0); len(after) != 0 { + t.Errorf("post-kill query = %+v, want empty", after) + } + // 5. abort a run still in flight (a prefixed `hang` registers its $job; abort // terminates it). Two sessions: one hangs, one aborts. const rid = "zzsesabort" diff --git a/internal/session/session.go b/internal/session/session.go index 25c955c..cbf0b1d 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -286,6 +286,80 @@ func (s *Session) ReadGlobal(ctx context.Context, req mdriver.GlobalRef) (mdrive return mdriver.GlobalNode{Ref: req.Ref, Value: string(raw)}, nil } +// KillGlobal kills a global node / subtree via an indirect kill (data.kill). +func (s *Session) KillGlobal(ctx context.Context, ref string) error { + _, status, eng, err := s.runScript(ctx, s.trapLine("kill @("+irisString(ref)+")")) + if err != nil { + return err + } + if eng != nil { + return fmt.Errorf("session: kill %s failed: %s %s", ref, eng.Mnemonic, eng.Text) + } + if status != 0 { + return fmt.Errorf("session: kill %s returned status %d", ref, status) + } + return nil +} + +// QueryGlobal walks the subtree rooted at ref and returns its contained nodes +// (data.query). It runs the $query walk directly in the session, writing each +// contained node ("Base64(ref)Base64(value)") to the device — captured +// between the markers like any session command — then parseNodes decodes it. The +// $name(@cur,bl)=ref containment check is the subtree test (collation makes the +// subtree contiguous, so the walk quits as soon as it leaves it). order is +// "forward"/"reverse"; depth>0 caps levels below ref (0 = the whole subtree). +func (s *Session) QueryGlobal(ctx context.Context, ref, order string, depth int) ([]mdriver.GlobalNode, error) { + dir := "1" + if order == "reverse" { + dir = "-1" + } + node := `$system.Encryption.Base64Encode(qcur)_$char(9)_$system.Encryption.Base64Encode($get(@qcur))_$char(10)` + base := `$system.Encryption.Base64Encode(qref)_$char(9)_$system.Encryption.Base64Encode($get(@qref))_$char(10)` + var b strings.Builder + fmt.Fprintf(&b, "write %q,!\n", beginMark) + fmt.Fprintf(&b, "set qref=%s,qdir=%s,qd=%d,qbl=$qlength(qref)\n", irisString(ref), dir, depth) + fmt.Fprintf(&b, "if $data(@qref)#10 write %s\n", base) + // for body is the (depth-filtered) per-node write; the RESULT marker is on the + // next line so it is not swept into the argumentless FOR body. + fmt.Fprintf(&b, "set qcur=qref for set qcur=$query(@qcur,qdir) quit:qcur=\"\" quit:$name(@qcur,qbl)'=qref write:'((qd>0)&((($qlength(qcur)-qbl))>qd)) %s\n", node) + fmt.Fprintf(&b, "write %q,\"0|\",! halt\n", endMark) + + captured, _, eng, err := s.runScript(ctx, b.String()) + if err != nil { + return nil, err + } + if eng != nil { + return nil, fmt.Errorf("session: query %s failed: %s %s", ref, eng.Mnemonic, eng.Text) + } + return parseNodes(captured) +} + +// parseNodes decodes a node list ("Base64(ref)Base64(value)" per line) into +// flat GlobalNodes. +func parseNodes(raw string) ([]mdriver.GlobalNode, error) { + var nodes []mdriver.GlobalNode + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimRight(line, "\r") + if line == "" { + continue + } + tab := strings.IndexByte(line, '\t') + if tab < 0 { + continue + } + ref, err := base64.StdEncoding.DecodeString(line[:tab]) + if err != nil { + return nil, fmt.Errorf("session: decode query ref: %w", err) + } + val, err := base64.StdEncoding.DecodeString(line[tab+1:]) + if err != nil { + return nil, fmt.Errorf("session: decode query value: %w", err) + } + nodes = append(nodes, mdriver.GlobalNode{Ref: string(ref), Value: string(val)}) + } + return nodes, nil +} + // Load stages routine source into the namespace and compiles it (exec.load). The // neutral ".m" source maps to a ".int" docname with the UDL ROUTINE header (the // same rules as the remote transport), is placed in the engine's filesystem diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 8d1fb06..2855c85 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -163,6 +163,45 @@ func TestReadGlobal_DecodesBase64(t *testing.T) { } } +func TestKillGlobal_BuildsIndirectKill(t *testing.T) { + fr := &fakeRun{fn: func(string) (CmdOutput, error) { + return CmdOutput{Stdout: sessionStdout("", 0, "")}, nil + }} + s := New(dockerCfg(), fr.run) + if err := s.KillGlobal(context.Background(), `^mFix("a")`); err != nil { + t.Fatalf("KillGlobal: %v", err) + } + if !strings.Contains(fr.lastStdin, `kill @(`) { + t.Errorf("kill stdin did not build an indirect kill:\n%s", fr.lastStdin) + } +} + +func TestQueryGlobal_DecodesNodeList(t *testing.T) { + // Two nodes as the session writes them: Base64(ref)Base64(value) per line. + node := func(ref, val string) string { + return base64.StdEncoding.EncodeToString([]byte(ref)) + "\t" + + base64.StdEncoding.EncodeToString([]byte(val)) + "\n" + } + captured := node(`^mFix("a")`, "1") + node(`^mFix("a","sub")`, "2") + fr := &fakeRun{fn: func(stdin string) (CmdOutput, error) { + // the walk runs against the principal device, bracketed by the markers + return CmdOutput{Stdout: sessionStdout(captured, 0, "")}, nil + }} + s := New(dockerCfg(), fr.run) + nodes, err := s.QueryGlobal(context.Background(), `^mFix("a")`, "forward", 0) + if err != nil { + t.Fatalf("QueryGlobal: %v", err) + } + if len(nodes) != 2 || nodes[0].Ref != `^mFix("a")` || nodes[0].Value != "1" { + t.Fatalf("nodes = %+v, want the 2 ^mFix(\"a\") subtree nodes", nodes) + } + // reverse order is carried into the $query direction. + _, _ = s.QueryGlobal(context.Background(), `^mFix`, "reverse", 2) + if !strings.Contains(fr.lastStdin, "qdir=-1") || !strings.Contains(fr.lastStdin, "qd=2") { + t.Errorf("query stdin did not carry order/depth:\n%s", fr.lastStdin) + } +} + func TestAbort_ReportsTerminatedPid(t *testing.T) { fr := &fakeRun{fn: func(string) (CmdOutput, error) { // The session abort eval writes the terminated pid into the captured region. diff --git a/main.go b/main.go index 65fc4cf..7afba39 100644 --- a/main.go +++ b/main.go @@ -48,7 +48,8 @@ type CLI struct { Meta metaCmd `cmd:"" help:"Introspection + power tools: caps / info / version / schema."` Lifecycle lifecycleCmd `cmd:"" help:"Manage the engine instance: up / down / restart / status / wait / provision / destroy."` Sync syncCmd `cmd:"" help:"Source axis: routine source ↔ instance (list / pull / status / verify / push / deploy / diff / rm)."` - Exec execCmd `cmd:"" help:"Exec axis: run M against the namespace (load / run / eval) via the remote runner."` + Exec execCmd `cmd:"" help:"Exec axis: run M against the namespace (load / run / eval / abort)."` + Data dataCmd `cmd:"" help:"Data axis: globals for fixtures + inspection (get / set / kill / query)."` InstallCompletions kongplete.InstallCompletions `cmd:"" help:"Install shell tab-completions."` } diff --git a/transport.go b/transport.go index 8a95502..b16db5a 100644 --- a/transport.go +++ b/transport.go @@ -11,19 +11,21 @@ import ( "github.com/vista-cloud-dev/m-iris/internal/session" ) -// execTransport is the neutral verb-level Transport plus the driver-local Abort -// verb (exec.abort is not an SDK Transport method — both the remote and session -// strategies satisfy it, like m-ydb's Session.Abort). The exec axis is written -// against this interface so it is transport-agnostic. -type execTransport interface { +// engineTransport is the neutral verb-level Transport plus the driver-local verbs +// that are not on the SDK seam (exec.abort, data.kill, data.query) — both the +// remote and session strategies satisfy it, so the exec + data axes are written +// transport-agnostically against this interface. +type engineTransport interface { mdriver.Transport Abort(ctx context.Context, prefix string) ([]string, error) + KillGlobal(ctx context.Context, ref string) error + QueryGlobal(ctx context.Context, ref, order string, depth int) ([]mdriver.GlobalNode, error) } // newExecTransport selects the transport strategy for the resolved connection: // the remote (Atelier REST + runner) transport, or the local/docker `iris // session` transport. It validates the inputs each strategy needs. -func newExecTransport(conn *config.Conn) (execTransport, error) { +func newExecTransport(conn *config.Conn) (engineTransport, error) { switch conn.Transport { case "", mdriver.TransportRemote: client, err := remoteClient(conn) From 31fa0f61d43b127ca02bb85b28ef4c43b23fbf58 Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Fri, 12 Jun 2026 16:14:38 -0400 Subject: [PATCH 19/24] docs(tracker): record M8 conformance 16/16 green on remote+docker (M8 -> in progress) The remote and docker transports already pass the full SDK conformance suite (16/16) via m-driver-conformance. M8 stays in progress pending local-transport validation (needs a host IRIS install) and the remaining cover/admin/native axes. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/m-iris-tracker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/m-iris-tracker.md b/docs/m-iris-tracker.md index ea96c26..fd75047 100644 --- a/docs/m-iris-tracker.md +++ b/docs/m-iris-tracker.md @@ -19,7 +19,7 @@ Pinned: `m-driver-sdk v0.2.0`. Branch: `m-iris-driver`. Transports: remote (Atel | M5 | cover (%Monitor.LineByLine → LCOV) | ☐ | port mcov.FromMonitor | | M6 | admin (backup/restore/check/journal) | ☐ | | | M7 | native passthrough (iris/atelier/sql) | ☐ | | -| M8 | conformance green local+docker+remote | ☐ | release gate | +| M8 | conformance green local+docker+remote | ◐ | release gate. **Conformance 16/16 already green on remote AND docker (2026-06-12)** via `m-driver-conformance --driver ./dist/m-iris --transport {remote,docker}`. Remaining: `local` (needs a host IRIS install to validate — same session code path as docker minus the `docker exec` wrap) + the axes still ☐ (cover/admin/native). | | DRV | **public `irisdriver` facade** | ☑ | `New(Config)→(mdriver.Transport,error)` over Atelier REST + runner; the importable seam for in-process embedders (vendor logic stays internal/). **Live-validated vs m-test-iris (2026.1):** New→Health→Exec($zv via result-global) returns the IRIS banner. | | CFM | **`meta version` conformance fix** | ☑ | Was the shared `clikit.VersionCmd` (`{version,commit,date,go}`) — non-conformant: contract §5.7 version = `{driver,engine,contract,build}` (caught by `m-driver-conformance`). Replaced with a driver-specific `versionCmd` emitting `{driver:"m-iris",engine:"iris",contract,build{…}}`; clikit untouched (byte-identical). **Conformance now 16/16 live vs m-test-iris (remote).** | | CFM2 | **clikit `ResultExit` + doctor envelope/exit** | ☑ | Mirrored the shared clikit fix (byte-identical with m-ydb): `Context.ResultExit(data, exit, text)` so `meta doctor` emits its data envelope with the resolved exit (0/5/6) and `Run` returns `cc.ExitCode()`. doctor's unreachable path now emits `ok=false, exit=6` with process exit 6 (was the latent `cc.Result`-then-`Fail` stdout-exit-0 mismatch). Conformance stays 16/16 live. | From 49a5b00a642ff0ae00a34bef6b00fd053aaae8ac Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Sat, 13 Jun 2026 02:01:55 -0400 Subject: [PATCH 20/24] fix(remote): GetOut handles wide-char (Unicode >255) output via UTF-8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner's GetOut Base64-encoded ^mIrisRun(rid,"out") directly, but $system.Encryption.Base64Encode requires an 8-bit byte string and faults GetOut+2^m.iris.Runner.1 on a captured value holding a char >255. The capture path makes this easy to hit (wchr(c) do app($char(c))), so a script's W $C(8212) (em-dash) turns the out global into a 16-bit string. This blocked the non-ASCII m-stdlib suites (STDURL/STDREGEX/STDJSON/STDXML) on the VSL T0b.2 IRIS remote leg — they errored with no result frame. Fix: GetOut now $zconvert(...,"O","UTF8") before Base64. UTF-8 leaves ASCII/<=127 bytes identical (the v-pkg KIDS marker path is byte-unchanged; the exec-axis IT stays green) and emits multi-byte sequences for the rest, so Base64 is always byte-safe. The Go getOut is unchanged: string(raw) of the Base64-decoded UTF-8 bytes is already the correct Go (UTF-8) string. TDD: TestRemoteWideChar_RealEngine (RED reproduced the exact fault) — `W "<>",$C(233),$C(8212),"end"` now round-trips to Stdout containing "<>é—end", proving the trailing result marker survives a wide char (the suites' failure mode). make test-it 6/6 RealEngine green; go test -race/vet/gofmt clean. Remote (Atelier) transport only — the docker/session transport captures via iris session stdout markers, a separate path. Downstream confirmation owed: re-run kids-test-in-place.sh iris on foia so the 4 suites frame. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/m-iris-tracker.md | 8 ++-- docs/memory/MEMORY.md | 2 +- docs/memory/m-iris-exec-axis-t0a5.md | 22 +++++++++++ internal/remote/integration_test.go | 49 ++++++++++++++++++++++++ internal/remote/remote.go | 12 +++--- internal/remote/runner/m.iris.Runner.cls | 18 ++++++--- 6 files changed, 96 insertions(+), 15 deletions(-) diff --git a/docs/m-iris-tracker.md b/docs/m-iris-tracker.md index fd75047..263661b 100644 --- a/docs/m-iris-tracker.md +++ b/docs/m-iris-tracker.md @@ -23,6 +23,7 @@ Pinned: `m-driver-sdk v0.2.0`. Branch: `m-iris-driver`. Transports: remote (Atel | DRV | **public `irisdriver` facade** | ☑ | `New(Config)→(mdriver.Transport,error)` over Atelier REST + runner; the importable seam for in-process embedders (vendor logic stays internal/). **Live-validated vs m-test-iris (2026.1):** New→Health→Exec($zv via result-global) returns the IRIS banner. | | CFM | **`meta version` conformance fix** | ☑ | Was the shared `clikit.VersionCmd` (`{version,commit,date,go}`) — non-conformant: contract §5.7 version = `{driver,engine,contract,build}` (caught by `m-driver-conformance`). Replaced with a driver-specific `versionCmd` emitting `{driver:"m-iris",engine:"iris",contract,build{…}}`; clikit untouched (byte-identical). **Conformance now 16/16 live vs m-test-iris (remote).** | | CFM2 | **clikit `ResultExit` + doctor envelope/exit** | ☑ | Mirrored the shared clikit fix (byte-identical with m-ydb): `Context.ResultExit(data, exit, text)` so `meta doctor` emits its data envelope with the resolved exit (0/5/6) and `Run` returns `cc.ExitCode()`. doctor's unreachable path now emits `ok=false, exit=6` with process exit 6 (was the latent `cc.Result`-then-`Fail` stdout-exit-0 mismatch). Conformance stays 16/16 live. | +| FIX | **remote `GetOut` wide-char (Unicode >255) output** | ☑ | **DONE 2026-06-13.** Runner `GetOut` Base64-encoded the captured `^mIrisRun(rid,"out")` directly, but `$system.Encryption.Base64Encode` requires an 8-bit byte string → faulted `GetOut+2^m.iris.Runner.1` whenever a script WROTE a char >255 (capture path `wchr(c) do app($char(c))` makes `out` a 16-bit string; `W $C(8212)` em-dash repros). Blocked the non-ASCII m-stdlib suites (STDURL/STDREGEX/STDJSON/STDXML) on the VSL T0b.2 IRIS remote leg — they errored with no result frame. **Fix:** `GetOut` now `$zconvert(...,"O","UTF8")` before Base64 (ASCII/≤127 byte-identical → KIDS markers unchanged; multi-byte for the rest → always byte-safe). Go `getOut` unchanged (`string(raw)` of decoded UTF-8 bytes is the correct Go string). New `TestRemoteWideChar_RealEngine` (`W "<>",$C(233),$C(8212),"end"` → `<>é—end`, trailing marker survives); `make test-it` 6/6 RealEngine green, race/vet/gofmt clean. **Remote (Atelier) transport only** — the docker/session transport captures via stdout markers (separate path). Downstream: re-run `kids-test-in-place.sh iris` on foia to confirm the 4 suites now frame. | **Device-capture note (UPDATED 2026-06-12 — supersedes the old "no IO redirection" note):** IRIS `Exec` now CAPTURES device `W` output. The runner's `RunRef`/`Eval` @@ -37,9 +38,10 @@ caveat:** `EN^XPDIJ` reconfigures the Atelier SQL-gateway device with USE-params ReDirectIO can't intercept, so the action/query RESPONSE BODY is lost (HTTP 200 + empty body) even though the run completes; the runner therefore records `status`/`out`/`error` in `^mIrisRun(rid,*)` and sets `"done"` LAST, and `Exec` -RECOVERS the outcome from those globals — Base64-encoded (`GetOut`) so control bytes -survive, retrying on fresh connections (`CloseIdleConnections`) until a clean -gateway process serves the read. `Health()`+Version remains the portable readiness +RECOVERS the outcome from those globals — UTF-8-then-Base64-encoded (`GetOut`) so +control bytes AND wide (Unicode >255) chars survive (see the FIX row), retrying on +fresh connections (`CloseIdleConnections`) until a clean gateway process serves the +read. `Health()`+Version remains the portable readiness probe; `W $ZV` via `Exec` now also works on IRIS. **needs SDK:** (record here any shared shape M3+ requires that isn't in the pinned diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index e7f8918..698c0e8 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -13,4 +13,4 @@ for how m-iris stays in lockstep with m-ydb via `m-driver-sdk`. - [m-iris driver M0–M2 + remote spike](m-iris-driver-m0-spike.md) — IRIS driver (D1), branch `m-iris-driver`. M0+M1+M2 done — sync axis 8-verb parity (diff/rm/push --from/bare-name filter); real-IRIS-2026.1 validated (404 + PutDoc result.status bugs fixed, 8c2f010). Atelier-SQL runner substrate gated. Next M3 exec. Pins m-driver-sdk v0.2.0. - [m-iris public facade](m-iris-public-facade.md) — NEW `irisdriver.New(Config)→mdriver.Transport` for m-cli/VistaEngine (peer of m-ydb's ydbdriver). Live-validated vs m-test-iris (banner returned). NOTE: the old "IRIS Exec does NOT capture device `W`" rule is **superseded** by [[m-iris-exec-axis-t0a5]] — the runner now redirects device output into `^mIrisRun(rid,"out")`. - [data axis (M4 get/set/kill/query)](m-iris-data-axis.md) — **M4 get/set/kill/query DONE (2026-06-12)**, conformance 16/16 remote+docker; export/import still ☐. The crux: `$query` subtree walk with `$name(@cur,$qlength(ref))=ref` containment (the `@cur` indirection is the gotcha — `$qsubscript` takes the string directly, `$name` needs `@`); shared node-list wire format `Base64(ref)\tBase64(value)\n`. -- [exec axis + T0a.5 driver-path](m-iris-exec-axis-t0a5.md) — **M3 DONE (2026-06-12)**: full exec axis (load/run/eval/abort) over BOTH remote (Atelier runner) and the new docker/local `iris session` transport; conformance 16/16 on remote AND docker. Has: the remote runner device-`W` capture + KIDS-over-Atelier corruption recovery; the session single-line TRY/CATCH `@@MIRIS-BEGIN/RESULT@@` capture protocol (`$ZTRAP` won't cross stdin-prompt lines); `exec abort` (`$job`+`^$JOB`+`Process.Terminate`); `transport.go` selector. T0a.5 (`v pkg … --engine iris`) proven on foia. SDK still v0.2.0. +- [exec axis + T0a.5 driver-path](m-iris-exec-axis-t0a5.md) — **M3 DONE (2026-06-12)**: full exec axis (load/run/eval/abort) over BOTH remote (Atelier runner) and the new docker/local `iris session` transport; conformance 16/16 on remote AND docker. Has: the remote runner device-`W` capture + KIDS-over-Atelier corruption recovery; the session single-line TRY/CATCH `@@MIRIS-BEGIN/RESULT@@` capture protocol (`$ZTRAP` won't cross stdin-prompt lines); `exec abort` (`$job`+`^$JOB`+`Process.Terminate`); `transport.go` selector. T0a.5 (`v pkg … --engine iris`) proven on foia. **Wide-char fix (2026-06-13):** remote `GetOut` now `$zconvert(...,"O","UTF8")` before Base64 (Base64Encode faulted `` on chars >255 — em-dash); unblocks the non-ASCII m-stdlib suites (STDURL/STDREGEX/STDJSON/STDXML) on the foia remote leg; see finding #5. SDK still v0.2.0. diff --git a/docs/memory/m-iris-exec-axis-t0a5.md b/docs/memory/m-iris-exec-axis-t0a5.md index 9fc02b3..74e5e1c 100644 --- a/docs/memory/m-iris-exec-axis-t0a5.md +++ b/docs/memory/m-iris-exec-axis-t0a5.md @@ -114,6 +114,28 @@ runner now captures it. Each layer below was a separate live-only failure: **`CloseIdleConnections()` and RETRIES** (up to ~2s) so a fresh connection lands on a clean process. Both were necessary; either alone still failed. +5. **Wide-char (Unicode >255) output (added 2026-06-13).** Finding 4's + `$system.Encryption.Base64Encode` requires an **8-bit byte string** — it faults + `GetOut+2^m.iris.Runner.1` on a captured value holding a char + >255. The capture path makes that easy to hit: `wchr(c) do app($char(c))`, so a + script's `W $C(8212)` (em-dash) appends a 16-bit char and the whole `out` global + becomes a wide string. Surfaced on the VSL T0b.2 IRIS test-in-place leg: m-stdlib + suites whose PASS-line descriptions are non-ASCII (STDURL/STDREGEX/STDJSON/STDXML) + **errored with no result frame** over the remote path. **Fix: `GetOut` now + `$zconvert(...,"O","UTF8")` BEFORE Base64** — UTF-8 leaves ASCII/≤127 bytes + identical (KIDS marker path byte-unchanged, exec-axis IT still green) and emits + multi-byte sequences for the rest; Base64 is then always byte-safe. The Go + `getOut` is **unchanged** — `string(raw)` of the Base64-decoded UTF-8 bytes is + already the correct Go (UTF-8) string. Proven by `TestRemoteWideChar_RealEngine` + (`W "<>",$C(233),$C(8212),"end"` → Stdout contains `<>é—end`; trailing + marker survives — the exact suite failure mode). **NB:** this is the **remote + (Atelier) transport** only; the docker/session transport captures via `iris + session` stdout markers (a separate path), so a wide-char issue there (if any) is + independent of this fix. **Downstream confirmation owed:** re-run + `kids-test-in-place.sh iris` on foia (remote) — the 4 suites should now produce + frames; m-cli's `m test` has no remote-IRIS transport, so they can't be re-run + over remote through the runner here. + **JOB-isolation was tried and rejected:** running the install in a JOB'd child keeps the SqlProc's device clean (no lost body) BUT the child's redirect drops at `XPDIJ`'s end (its principal differs), truncating capture before the marker. Inline diff --git a/internal/remote/integration_test.go b/internal/remote/integration_test.go index ec2bdc1..3d196ab 100644 --- a/internal/remote/integration_test.go +++ b/internal/remote/integration_test.go @@ -299,6 +299,55 @@ func TestRemoteData_RealEngine(t *testing.T) { } } +// TestRemoteWideChar_RealEngine proves the runner captures and returns output +// containing wide (Unicode >255) characters. Before the fix, GetOut's +// $system.Encryption.Base64Encode faulted on a captured string +// holding a char >255 (Base64Encode requires an 8-bit byte string), so any suite +// that WRITEs Unicode — STDURL/STDREGEX/STDJSON/STDXML PASS-line descriptions — +// came back with no result frame. The runner now UTF-8-encodes the captured +// string before Base64, so the em-dash round-trips to its UTF-8 form in Stdout. +// +// Gated identically (M_IRIS_IT=1 + M_IRIS_* connection env). +func TestRemoteWideChar_RealEngine(t *testing.T) { + if os.Getenv("M_IRIS_IT") != "1" { + t.Skip("set M_IRIS_IT=1 (+ M_IRIS_* connection env) to run the real-engine wide-char test") + } + client, err := atelier.New(atelier.Config{ + BaseURL: envOr("M_IRIS_BASE_URL", "http://localhost:52773/api/atelier/v1/"), + Namespace: envOr("M_IRIS_NAMESPACE", "USER"), + User: envOr("M_IRIS_USER", "_SYSTEM"), + Password: envOr("M_IRIS_PASSWORD", "SYS"), + Timeout: 30 * time.Second, + }) + if err != nil { + t.Fatalf("atelier client: %v", err) + } + tr := New(client) + ctx := context.Background() + t.Cleanup(func() { + _, _ = client.Query(ctx, "SELECT m_iris.KillGlobal(?)", `^mIrisRun("zzwide")`) + _ = client.DeleteDoc(ctx, runnerDoc) + _ = client.DeleteDoc(ctx, ioHelperDoc) + }) + + // Write an ASCII marker, a Latin-1 char ($C(233)=é, the 128-255 range), the + // em-dash (U+2014 = $C(8212), the >255 repro char that faulted Base64Encode), + // and a trailing marker so a truncating capture would drop the tail. All three + // ranges must round-trip to their UTF-8 form in Stdout. + res, err := tr.Exec(ctx, mdriver.ExecRequest{ + Command: `W "<>",$C(233),$C(8212),"end"`, Prefix: "zzwide", + }) + if err != nil { + t.Fatalf("Exec wide-char eval returned a Go error: %v", err) + } + if res.EngineError != nil { + t.Fatalf("Exec wide-char eval faulted: %+v", res.EngineError) + } + if want := "<>é—end"; !strings.Contains(res.Stdout, want) { + t.Fatalf("Stdout = %q, want it to contain %q (é + em-dash round-trip as UTF-8)", res.Stdout, want) + } +} + func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/internal/remote/remote.go b/internal/remote/remote.go index c0a8dca..ba9a12e 100644 --- a/internal/remote/remote.go +++ b/internal/remote/remote.go @@ -311,11 +311,13 @@ func parseNodes(raw string) ([]mdriver.GlobalNode, error) { return nodes, nil } -// getOut reads the captured result-global text for a run, Base64-encoded by the -// runner so control bytes (a KIDS install's ANSI/terminal output) survive the -// action/query JSON transport — a raw read truncates at the first non-text byte, -// dropping the trailing result markers v-pkg parses. IRIS Base64Encode may wrap -// the encoded text at 76 columns, so strip whitespace before decoding. +// getOut reads the captured result-global text for a run, UTF-8-then-Base64-encoded +// by the runner so control bytes (a KIDS install's ANSI/terminal output) survive +// the action/query JSON transport — a raw read truncates at the first non-text +// byte, dropping the trailing result markers v-pkg parses. The runner UTF-8-encodes +// first so wide (Unicode >255) output is byte-safe for Base64; the decoded bytes +// are UTF-8, which string(raw) turns straight into a Go string. IRIS Base64Encode +// may wrap the encoded text at 76 columns, so strip whitespace before decoding. func (t *Transport) getOut(ctx context.Context, rid string) (string, error) { rows, err := t.api.Query(ctx, "SELECT m_iris.GetOut(?) AS out", rid) if err != nil { diff --git a/internal/remote/runner/m.iris.Runner.cls b/internal/remote/runner/m.iris.Runner.cls index 07c2543..2731db2 100644 --- a/internal/remote/runner/m.iris.Runner.cls +++ b/internal/remote/runner/m.iris.Runner.cls @@ -89,16 +89,22 @@ ClassMethod Eval(rid As %String, cmd As %String) As %Integer [ SqlName = "Eval", quit ^mIrisRun(rid,"status") } -/// GetOut returns ^mIrisRun(rid,"out") Base64-encoded so a runner result that -/// contains control bytes — a KIDS install's ANSI/terminal output (ESC, CR, page -/// controls) — survives the action/query JSON transport intact. A raw read of -/// such a value truncates/mangles at the first non-text byte, dropping the -/// trailing result markers. (contract: Stdout = runner result-global text.) +/// GetOut returns ^mIrisRun(rid,"out") UTF-8-encoded then Base64-encoded, so a +/// runner result survives the action/query JSON transport intact: Base64 keeps +/// control bytes (a KIDS install's ANSI/terminal output — ESC, CR, page controls) +/// alive (a raw read truncates/mangles at the first non-text byte, dropping the +/// trailing result markers), and the $ZCONVERT-to-UTF8 first makes Base64 byte-safe +/// for WIDE output too — a captured value holding a char >255 (e.g. a suite that +/// WRITEs an em-dash, $C(8212)) is a 16-bit string, which Base64Encode rejects +/// outright with . UTF-8 leaves ASCII/≤127 bytes identical (so the +/// KIDS marker path is byte-unchanged) and emits multi-byte sequences for the rest; +/// the Go side reads the Base64-decoded bytes straight into a (UTF-8) string. +/// (contract: Stdout = runner result-global text.) /// SQL: SELECT m_iris.GetOut(?) ClassMethod GetOut(rid As %String) As %String [ SqlName = "GetOut", SqlProc ] { if '..authorized() quit "" - quit $system.Encryption.Base64Encode($get(^mIrisRun(rid,"out"))) + quit $system.Encryption.Base64Encode($zconvert($get(^mIrisRun(rid,"out")),"O","UTF8")) } /// GetGlobal returns $get(@ref); ref is a full global reference, e.g. From f59fed875774a9a2dc1178c2d6019c95b8dbd4e3 Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Sat, 13 Jun 2026 23:14:31 -0400 Subject: [PATCH 21/24] chore(arch): declare layer m (waterline G1) Add repo.meta.json with "layer": "m" so `m arch check` reads m-iris's side of the m/v waterline. m-iris is an engine-neutral driver; G1 is clean (no vista-cloud-dev/v-* dep, no VSL* refs). Co-Authored-By: Claude Opus 4.8 (1M context) --- repo.meta.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 repo.meta.json diff --git a/repo.meta.json b/repo.meta.json new file mode 100644 index 0000000..97a4a55 --- /dev/null +++ b/repo.meta.json @@ -0,0 +1,9 @@ +{ + "id": "tool:m-iris", + "repo": "https://github.com/vista-cloud-dev/m-iris", + "role": "InterSystems IRIS engine driver — the m-iris binary producing the driver envelope", + "language": ["go"], + "layer": "m", + "license": "AGPL-3.0", + "verification_commands": ["go test ./...", "./dist/m arch check ."] +} From 694fa487cf3f9da214a6c6701b0a6c4fdee45943 Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Sun, 14 Jun 2026 07:28:18 -0400 Subject: [PATCH 22/24] ci(arch): adopt the reusable m/v waterline G1 gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the arch job calling vista-cloud-dev/.github arch-waterline.yml@main, so `m arch check` (the G1 dependency-direction gate) runs in CI on every push/PR — the ADR §3.3 'no exception' enforcement, not just the local make gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74e2377..9df75fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,3 +8,5 @@ on: jobs: ci: uses: vista-cloud-dev/.github/.github/workflows/go-ci.yml@main + arch: + uses: vista-cloud-dev/.github/.github/workflows/arch-waterline.yml@main From 795a630f3a13b632e756161c99cbc004d9738658 Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Sun, 14 Jun 2026 08:44:45 -0400 Subject: [PATCH 23/24] ci: disable schema-check (driver binary, no `schema` command) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9df75fe..7b95416 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,5 +8,7 @@ on: jobs: ci: uses: vista-cloud-dev/.github/.github/workflows/go-ci.yml@main + with: + schema-check: false # driver binary — no m-cli-style `schema` command arch: uses: vista-cloud-dev/.github/.github/workflows/arch-waterline.yml@main From da0f153cc382bea7baf2ea52117bc82e819c9f29 Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Sun, 14 Jun 2026 08:51:27 -0400 Subject: [PATCH 24/24] chore(lint): fix golangci-lint findings to land on main (Phase A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical lint hygiene so the go-ci gate passes (tests already green, conformance 16/16): errcheck on httptest w.Write/io.WriteString in test handlers (_, _ = …), two unused-param `cc` in the remote-unsupported lifecycle command stubs (→ _), an unused test param, and a comment typo (modelling→modeling). No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- doctor_test.go | 4 ++-- internal/atelier/query_test.go | 4 ++-- internal/atelier/serverinfo_test.go | 2 +- internal/remote/remote_test.go | 2 +- internal/session/session_test.go | 2 +- lifecycle.go | 4 ++-- lifecycle_test.go | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/doctor_test.go b/doctor_test.go index 0acf78e..9842e24 100644 --- a/doctor_test.go +++ b/doctor_test.go @@ -18,7 +18,7 @@ func doctorServer(rootCode int, namespaces []string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if strings.Contains(r.URL.Path, "/action/query") { - w.Write([]byte(`{"status":{"errors":[]},"result":{"content":[{"one":"1"}]}}`)) + _, _ = w.Write([]byte(`{"status":{"errors":[]},"result":{"content":[{"one":"1"}]}}`)) return } // root descriptor @@ -27,7 +27,7 @@ func doctorServer(rootCode int, namespaces []string) *httptest.Server { return } nsJSON, _ := json.Marshal(namespaces) - w.Write([]byte(`{"status":{"errors":[]},"result":{"content":{` + + _, _ = w.Write([]byte(`{"status":{"errors":[]},"result":{"content":{` + `"version":"IRIS for UNIX 2024.1","api":7,"namespaces":` + string(nsJSON) + `}}}`)) })) } diff --git a/internal/atelier/query_test.go b/internal/atelier/query_test.go index e7efd20..437fcec 100644 --- a/internal/atelier/query_test.go +++ b/internal/atelier/query_test.go @@ -22,7 +22,7 @@ func TestQuery_RoundTrip(t *testing.T) { b, _ := io.ReadAll(r.Body) gotBody = string(b) w.Header().Set("Content-Type", "application/json") - io.WriteString(w, `{"status":{"errors":[]},"console":[],"result":{"content":[`+ + _, _ = io.WriteString(w, `{"status":{"errors":[]},"console":[],"result":{"content":[`+ `{"status":"0","error":""}`+ `]}}`) })) @@ -61,7 +61,7 @@ func TestQuery_ServerError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - io.WriteString(w, `{"status":{"errors":[{"error":"[SQLCODE: <-99>] privilege failure","code":"99"}]},"result":{}}`) + _, _ = io.WriteString(w, `{"status":{"errors":[{"error":"[SQLCODE: <-99>] privilege failure","code":"99"}]},"result":{}}`) })) defer srv.Close() diff --git a/internal/atelier/serverinfo_test.go b/internal/atelier/serverinfo_test.go index 05df45e..0834dc6 100644 --- a/internal/atelier/serverinfo_test.go +++ b/internal/atelier/serverinfo_test.go @@ -17,7 +17,7 @@ func TestServerInfo_RoundTrip(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"status":{"errors":[]},"result":{"content":{` + + _, _ = w.Write([]byte(`{"status":{"errors":[]},"result":{"content":{` + `"version":"IRIS for UNIX (Ubuntu Server LTS) 2024.1","api":7,` + `"namespaces":["%SYS","USER","VISTA"]}}}`)) })) diff --git a/internal/remote/remote_test.go b/internal/remote/remote_test.go index 7d5d880..db9d91f 100644 --- a/internal/remote/remote_test.go +++ b/internal/remote/remote_test.go @@ -15,7 +15,7 @@ import ( // fakeAPI scripts the runner's SQL surface in-memory: it records PUT/Compile // (so we can assert the runner is deployed exactly once) and answers Query by -// dispatching on the SQL + bound parameters, modelling ^mIrisRun. +// dispatching on the SQL + bound parameters, modeling ^mIrisRun. type fakeAPI struct { puts []string putBody map[string][]string // docname → content (last PUT) diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 2855c85..73ba39c 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -183,7 +183,7 @@ func TestQueryGlobal_DecodesNodeList(t *testing.T) { base64.StdEncoding.EncodeToString([]byte(val)) + "\n" } captured := node(`^mFix("a")`, "1") + node(`^mFix("a","sub")`, "2") - fr := &fakeRun{fn: func(stdin string) (CmdOutput, error) { + fr := &fakeRun{fn: func(_ string) (CmdOutput, error) { // the walk runs against the principal device, bracketed by the markers return CmdOutput{Stdout: sessionStdout(captured, 0, "")}, nil }} diff --git a/lifecycle.go b/lifecycle.go index e9ac7e0..05f4150 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -281,14 +281,14 @@ func (c *lifeWaitCmd) Run(cc *clikit.Context, conn *config.Conn) error { type lifeProvisionCmd struct{} -func (lifeProvisionCmd) Run(cc *clikit.Context, conn *config.Conn) error { +func (lifeProvisionCmd) Run(_ *clikit.Context, conn *config.Conn) error { return unsupportedOnRemote(conn, "provision", "create the namespace on the server, then attach with --transport remote") } type lifeDestroyCmd struct{} -func (lifeDestroyCmd) Run(cc *clikit.Context, conn *config.Conn) error { +func (lifeDestroyCmd) Run(_ *clikit.Context, conn *config.Conn) error { return unsupportedOnRemote(conn, "destroy", "drop the namespace on the server directly; m-iris remote only manages routines") } diff --git a/lifecycle_test.go b/lifecycle_test.go index 69e49ec..bf03507 100644 --- a/lifecycle_test.go +++ b/lifecycle_test.go @@ -14,7 +14,7 @@ import ( func rootServer() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"status":{"errors":[]},"result":{"content":{` + + _, _ = w.Write([]byte(`{"status":{"errors":[]},"result":{"content":{` + `"version":"IRIS for UNIX 2024.1","api":7,"namespaces":["%SYS","USER","VISTA"]}}}`)) })) }