diff --git a/Dockerfile b/Dockerfile index c347733..bb84746 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # Build stage with required Node + Go -FROM node:22.6.0-alpine3.22 AS build-env +FROM node:22-alpine AS build-env ARG GOPROXY=direct ARG GITEA_VERSION @@ -41,7 +41,7 @@ RUN chmod 755 /tmp/local/usr/bin/entrypoint \ /tmp/local/etc/s6/gitea/* \ /tmp/local/etc/s6/openssh/* \ /tmp/local/etc/s6/.s6-svscan/* \ - /src/gitea \ + /src/processgit \ /src/processgit-seed # Runtime stage @@ -67,7 +67,7 @@ RUN addgroup -S -g 1000 git && \ echo "git:*" | chpasswd -e COPY --from=build-env /tmp/local / -COPY --from=build-env /src/gitea /app/gitea/gitea +COPY --from=build-env /src/processgit /app/gitea/gitea COPY --from=build-env /src/processgit-seed /app/processgit-seed COPY --from=build-env /src/templates /app/gitea/templates COPY --from=build-env /src/public /app/gitea/public diff --git a/deploy/docker-compose.uapf.example.yml b/deploy/docker-compose.uapf.example.yml new file mode 100644 index 0000000..2232582 --- /dev/null +++ b/deploy/docker-compose.uapf.example.yml @@ -0,0 +1,66 @@ +# UAPF execution MCP scope — release-bundle additions +# +# Wires the per-repo /{owner}/{repo}/uapf-mcp execution scope (added in +# routers/web/repo/uapfmcp.go) to a UAPF engine and, optionally, a capability +# host (LLM gateway). Apply alongside the base compose: +# +# # Fresh install (ProcessGit ships its own engine): +# docker compose -f docker-compose.yml -f docker-compose.uapf.example.yml \ +# --profile bundled-engine up -d +# +# # Host where an engine already runs on the same network (e.g. Kojusalas: +# # the opendms stack already provides 'uapf-engine' on deploy_default): +# docker compose -f docker-compose.yml -f docker-compose.uapf.example.yml up -d +# +# Backward compatible: the env below is inert unless a repo has a uapf.yaml and +# an agent connects to /uapf-mcp. A processgit image built from this branch is +# required for the endpoint to exist. + +services: + processgit: + environment: + # Engine the /uapf-mcp scope delegates execution to (internal network). + UAPF_ENGINE_URL: "http://uapf-engine:4000" + # Where the engine fetches package archives from — internal ProcessGit, so + # no public egress and no dependence on the external hostname. + UAPF_ARCHIVE_BASE: "http://processgit:3000" + # Host identity advertised to the engine in the session hostManifest. + UAPF_HOST_DID: "did:web:processgit.local" + # Capability host implementing /uapf/host/capability/* for ai.* operations + # (ai.redact@1, ai.extract@1, ...). REQUIRED only for packages that declare + # ai.* in requires_capabilities; pure DMN/BPMN packages run without it. + # Point at your LLM gateway (see llm-gateway note below). + UAPF_HOST_BASE_URL: "${UAPF_HOST_BASE_URL:-}" + + # --- Bundled UAPF engine (fresh installs) ----------------------------------- + # Enabled only with `--profile bundled-engine`. On hosts where an engine is + # already present on the same docker network (Kojusalas: the opendms stack + # provides 'uapf-engine' on deploy_default), DO NOT enable this profile — the + # processgit env above already reaches the existing engine. + uapf-engine: + profiles: ["bundled-engine"] + image: "${UAPF_ENGINE_IMAGE:-opendms-uapf-engine:1.3.0}" + container_name: uapf-engine + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:4000/health >/dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + + # --- Capability host (LLM gateway) ------------------------------------------ + # No image ships with ProcessGit: the capability host is a UAPF host adapter + # that implements POST /uapf/host/capability/{namespace}/{operation} and backs + # ai.* operations with your LLM, kept inside the perimeter. Provide it and set + # UAPF_HOST_BASE_URL (e.g. http://llm-gateway:8080). Skeleton: + # + # llm-gateway: + # profiles: ["bundled-gateway"] + # image: + # container_name: llm-gateway + # restart: unless-stopped + # healthcheck: + # test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health >/dev/null 2>&1 || exit 1"] + # interval: 10s + # timeout: 3s + # retries: 12 diff --git a/go.mod b/go.mod index b06362f..4b81c06 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/ProtonMail/go-crypto v1.3.0 github.com/PuerkitoBio/goquery v1.10.3 github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0 + github.com/UAPFormat/uapf-mcp-go v0.1.1 github.com/alecthomas/chroma/v2 v2.21.1 github.com/aws/aws-sdk-go-v2/credentials v1.18.10 github.com/aws/aws-sdk-go-v2/service/codecommit v1.32.2 diff --git a/go.sum b/go.sum index ac70239..c0ba797 100644 --- a/go.sum +++ b/go.sum @@ -95,6 +95,8 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0 h1:tgjwQrDH5m6jIYB7kac5IQZmfUzQNseac/e3H4VoCNE= github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0/go.mod h1:1HmmMEVsr+0R1QWahSeMJkjSkq6CYAZu1aIbYSpfJ4o= +github.com/UAPFormat/uapf-mcp-go v0.1.1 h1:L6WpUzAMXhVcRdC3Fj8jZZ8rT8SYkmTNBua7H/PFsYg= +github.com/UAPFormat/uapf-mcp-go v0.1.1/go.mod h1:paBr1tXTrfc7WdJ3Oas9zf+WOEjjBfDhc8z5XupAYAo= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= @@ -456,8 +458,6 @@ github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8 github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= -github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1 h1:LqbZZ9sNMWVjeXS4NN5oVvhMjDyLhmA1LG86oSo+IqY= diff --git a/modules/diagrams/detect.go b/modules/diagrams/detect.go index 73693b5..9cb67de 100644 --- a/modules/diagrams/detect.go +++ b/modules/diagrams/detect.go @@ -16,6 +16,7 @@ const ( DiagramDMN DiagramType = "dmn" DiagramNGraph DiagramType = "ngraph" DiagramRuleset DiagramType = "ruleset" + DiagramCard DiagramType = "card" // UAPF v2.5.0+ algorithm card DiagramNone DiagramType = "none" ) @@ -33,7 +34,7 @@ type rulesetMetadata struct { func (d DiagramType) Editable() bool { switch d { - case DiagramBPMN, DiagramCMMN, DiagramDMN: + case DiagramBPMN, DiagramCMMN, DiagramDMN, DiagramCard: return true default: return false @@ -73,6 +74,10 @@ func detectByExtension(pathLower string) (DiagramType, string) { return DiagramRuleset, "xml" case strings.HasSuffix(pathLower, ".ruleset"): return DiagramRuleset, "" + case strings.HasSuffix(pathLower, ".card.yaml"), strings.HasSuffix(pathLower, ".card.yml"): + return DiagramCard, "yaml" + case strings.HasSuffix(pathLower, ".card.json"): + return DiagramCard, "json" default: return DiagramNone, "" } @@ -162,6 +167,8 @@ func defaultFormatForType(diagramType DiagramType) string { if diagramType != DiagramNone { return "json" } + case DiagramCard: + return "yaml" } return "" } diff --git a/modules/uapf/exporter.go b/modules/uapf/exporter.go index dd310bf..57255f4 100644 --- a/modules/uapf/exporter.go +++ b/modules/uapf/exporter.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "os" - "slices" "strings" repo_model "code.gitea.io/gitea/models/repo" @@ -36,49 +35,32 @@ func ExportUAPF(ctx context.Context, repo *repo_model.Repository, ref string) (i return nil, "", err } - manifestEntry, err := commit.GetTreeEntryByPath("manifest.json") + manifestName, manifestEntry, err := findManifestEntry(commit) if err != nil { - if git.IsErrNotExist(err) { - return nil, "", fmt.Errorf("manifest.json not found at ref %s", ref) - } return nil, "", err } manifestData, err := readTreeEntry(manifestEntry) if err != nil { - return nil, "", fmt.Errorf("read manifest.json: %w", err) + return nil, "", fmt.Errorf("read %s: %w", manifestName, err) } - if err := ValidateManifest(manifestData); err != nil { + if err := ValidateManifest(manifestName, manifestData); err != nil { return nil, "", err } - var manifest spec.Manifest - if err := json.Unmarshal(manifestData, &manifest); err != nil { - return nil, "", fmt.Errorf("manifest.json is not valid JSON: %w", err) - } - - refPaths, err := spec.ValidateManifest(&manifest) + manifestJSON, err := manifestToJSON(manifestName, manifestData) if err != nil { return nil, "", err } - requiredPaths := make(map[string]struct{}, len(refPaths)) - for _, rel := range refPaths { - if rel == "" { - continue - } - entry, err := commit.GetTreeEntryByPath(rel) - if err != nil { - if git.IsErrNotExist(err) { - return nil, "", fmt.Errorf("referenced path missing at ref %s: %s", ref, rel) - } - return nil, "", err - } - if entry.IsDir() { - return nil, "", fmt.Errorf("referenced path must be a file: %s", rel) - } - requiredPaths[rel] = struct{}{} + var manifest spec.Manifest + if err := json.Unmarshal(manifestJSON, &manifest); err != nil { + return nil, "", fmt.Errorf("manifest is not valid: %w", err) + } + + if err := spec.ValidateManifest(&manifest); err != nil { + return nil, "", err } entries, err := commit.Tree.ListEntriesRecursiveFast() @@ -89,7 +71,7 @@ func ExportUAPF(ctx context.Context, repo *repo_model.Repository, ref string) (i pr, pw := io.Pipe() go func() { zw := zip.NewWriter(pw) - if err := writeBytesEntry(zw, "manifest.json", manifestData); err != nil { + if err := writeBytesEntry(zw, manifestName, manifestData); err != nil { _ = pw.CloseWithError(err) return } @@ -99,8 +81,7 @@ func ExportUAPF(ctx context.Context, repo *repo_model.Repository, ref string) (i continue } name := entry.Name() - if name == "" || name == "manifest.json" { - delete(requiredPaths, name) + if name == "" || name == manifestName { continue } if entry.IsSubModule() { @@ -111,17 +92,6 @@ func ExportUAPF(ctx context.Context, repo *repo_model.Repository, ref string) (i _ = pw.CloseWithError(err) return } - delete(requiredPaths, name) - } - - if len(requiredPaths) > 0 { - missing := make([]string, 0, len(requiredPaths)) - for path := range requiredPaths { - missing = append(missing, path) - } - slices.Sort(missing) - _ = pw.CloseWithError(fmt.Errorf("referenced path missing at ref %s: %s", ref, strings.Join(missing, ", "))) - return } if err := zw.Close(); err != nil { @@ -135,16 +105,26 @@ func ExportUAPF(ctx context.Context, repo *repo_model.Repository, ref string) (i return pr, filename, nil } +// findManifestEntry locates the UAPF manifest in a commit tree, returning the +// highest-priority accepted manifest file name and its tree entry. +func findManifestEntry(commit *git.Commit) (string, *git.TreeEntry, error) { + for _, name := range ManifestNames { + entry, err := commit.GetTreeEntryByPath(name) + if err == nil { + return name, entry, nil + } + if !git.IsErrNotExist(err) { + return "", nil, err + } + } + return "", nil, fmt.Errorf("a UAPF manifest (uapf.yaml) is required in the repository") +} + func buildExportFilename(repo *repo_model.Repository, manifest spec.Manifest) string { name := manifest.Name version := manifest.Version - if manifest.Package != nil { - if manifest.Package.Name != "" { - name = manifest.Package.Name - } - if manifest.Package.Version != "" { - version = manifest.Package.Version - } + if name == "" { + name = manifest.ID } if name == "" { name = repo.Name diff --git a/modules/uapf/importer.go b/modules/uapf/importer.go index 9a2bdfb..37155b4 100644 --- a/modules/uapf/importer.go +++ b/modules/uapf/importer.go @@ -64,38 +64,36 @@ func ImportUAPF(ctx context.Context, repo *repo_model.Repository, doer *user_mod return err } - packageRoot, err := determinePackageRoot(tempDir) + packageRoot, manifestName, err := determinePackageRoot(tempDir) if err != nil { return err } - manifestPath := filepath.Join(packageRoot, "manifest.json") - manifestBytes, err := os.ReadFile(manifestPath) + manifestBytes, err := os.ReadFile(filepath.Join(packageRoot, manifestName)) if err != nil { - return fmt.Errorf("manifest.json is required in the UAPF package") + return fmt.Errorf("a UAPF manifest (uapf.yaml) is required in the package") } - if err := ValidateManifest(manifestBytes); err != nil { + if err := ValidateManifest(manifestName, manifestBytes); err != nil { + return err + } + + manifestJSON, err := manifestToJSON(manifestName, manifestBytes) + if err != nil { return err } var manifest spec.Manifest - if err := json.Unmarshal(manifestBytes, &manifest); err != nil { - return fmt.Errorf("manifest.json is not valid JSON: %w", err) + if err := json.Unmarshal(manifestJSON, &manifest); err != nil { + return fmt.Errorf("manifest is not valid: %w", err) } - refPaths, err := spec.ValidateManifest(&manifest) - if err != nil { + if err := spec.ValidateManifest(&manifest); err != nil { return err } - for _, ref := range refPaths { - if ref == "" { - return fmt.Errorf("referenced path cannot be empty") - } - if _, err := os.Stat(filepath.Join(packageRoot, filepath.FromSlash(ref))); err != nil { - return fmt.Errorf("referenced path missing in package: %s", ref) - } + if err := spec.ValidatePackageStructure(&manifest, osFileChecker{root: packageRoot}); err != nil { + return err } targetPath, err = normalizeTargetPath(targetPath) @@ -109,21 +107,15 @@ func ImportUAPF(ctx context.Context, repo *repo_model.Repository, doer *user_mod } if commitMsg == "" { - version := manifest.Version name := manifest.Name - if manifest.Package != nil { - if manifest.Package.Name != "" { - name = manifest.Package.Name - } - if manifest.Package.Version != "" { - version = manifest.Package.Version - } + if name == "" { + name = manifest.ID } if name == "" { name = "UAPF package" } - if version != "" { - commitMsg = fmt.Sprintf("Import UAPF package %s@%s", name, version) + if manifest.Version != "" { + commitMsg = fmt.Sprintf("Import UAPF package %s@%s", name, manifest.Version) } else { commitMsg = fmt.Sprintf("Import UAPF package %s", name) } @@ -202,24 +194,63 @@ func writeFile(dst string, r io.Reader, mode os.FileMode) error { return nil } -func determinePackageRoot(tempDir string) (string, error) { +func determinePackageRoot(tempDir string) (string, string, error) { + if name := findManifestName(tempDir); name != "" { + return tempDir, name, nil + } + entries, err := os.ReadDir(tempDir) if err != nil { - return "", fmt.Errorf("read archive contents: %w", err) + return "", "", fmt.Errorf("read archive contents: %w", err) } - if len(entries) == 1 && entries[0].IsDir() { single := filepath.Join(tempDir, entries[0].Name()) - if _, err := os.Stat(filepath.Join(single, "manifest.json")); err == nil { - return single, nil + if name := findManifestName(single); name != "" { + return single, name, nil } } - if _, err := os.Stat(filepath.Join(tempDir, "manifest.json")); err == nil { - return tempDir, nil + return "", "", fmt.Errorf("a UAPF manifest (uapf.yaml) is required in the package") +} + +// findManifestName returns the highest-priority manifest file name present +// directly in dir, or an empty string when none is found. +func findManifestName(dir string) string { + for _, n := range ManifestNames { + if info, err := os.Stat(filepath.Join(dir, n)); err == nil && !info.IsDir() { + return n + } } + return "" +} + +// osFileChecker implements spec.FileChecker against an extracted package dir. +type osFileChecker struct { + root string +} + +// FileExists reports whether the package-relative file exists and is a file. +func (c osFileChecker) FileExists(rel string) bool { + info, err := os.Stat(filepath.Join(c.root, filepath.FromSlash(rel))) + return err == nil && !info.IsDir() +} - return "", fmt.Errorf("manifest.json is required in the UAPF package") +// DirHasFiles reports whether dir contains at least one matching file. +func (c osFileChecker) DirHasFiles(dir, suffix string) bool { + entries, err := os.ReadDir(filepath.Join(c.root, filepath.FromSlash(dir))) + if err != nil { + return false + } + suffix = strings.ToLower(suffix) + for _, e := range entries { + if e.IsDir() { + continue + } + if suffix == "" || strings.HasSuffix(strings.ToLower(e.Name()), suffix) { + return true + } + } + return false } func normalizeTargetPath(target string) (string, error) { diff --git a/modules/uapf/locate.go b/modules/uapf/locate.go new file mode 100644 index 0000000..ce9e01b --- /dev/null +++ b/modules/uapf/locate.go @@ -0,0 +1,71 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package uapf + +import ( + "fmt" + "path/filepath" + "strings" + + "code.gitea.io/gitea/modules/json" + + "gopkg.in/yaml.v3" +) + +// ManifestNames lists accepted UAPF manifest file names in priority order. +// UAPF v2.2.0 mandates uapf.yaml; the remaining names are accepted for +// compatibility with UAPF-IP packages and legacy archives. Whatever the file +// name, the contents are validated against the UAPF v2.2.0 manifest schema. +var ManifestNames = []string{ + "uapf.yaml", + "uapf.yml", + "process.uapf.yaml", + "process.uapf.yml", + "manifest.json", +} + +// IsManifestName reports whether base (a file name, not a path) is an accepted +// UAPF manifest file name. +func IsManifestName(base string) bool { + for _, n := range ManifestNames { + if base == n { + return true + } + } + return false +} + +// manifestPriority returns the priority index of a manifest base name; a lower +// value is preferred. Unknown names sort last. +func manifestPriority(base string) int { + for i, n := range ManifestNames { + if base == n { + return i + } + } + return len(ManifestNames) +} + +// isYAMLName reports whether the file name denotes a YAML document. +func isYAMLName(name string) bool { + l := strings.ToLower(name) + return strings.HasSuffix(l, ".yaml") || strings.HasSuffix(l, ".yml") +} + +// manifestToJSON normalizes manifest bytes to JSON, decoding YAML when the file +// name denotes a YAML document. JSON manifests are returned unchanged. +func manifestToJSON(name string, data []byte) ([]byte, error) { + if !isYAMLName(name) { + return data, nil + } + var v any + if err := yaml.Unmarshal(data, &v); err != nil { + return nil, fmt.Errorf("%s is not valid YAML: %w", filepath.Base(name), err) + } + out, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("normalize %s: %w", filepath.Base(name), err) + } + return out, nil +} diff --git a/modules/uapf/spec/manifest.go b/modules/uapf/spec/manifest.go index 1b26a69..6e0cf09 100644 --- a/modules/uapf/spec/manifest.go +++ b/modules/uapf/spec/manifest.go @@ -3,27 +3,81 @@ package spec -// Manifest describes the structure of a UAPF manifest.json file. -// It mirrors the embedded schema and captures the references we need to validate. +// Manifest mirrors the UAPF v2.2.0 package manifest (uapf.yaml). Only the +// fields ProcessGit consumes are modelled here; full structural validation of +// the manifest is performed against the embedded JSON schema. type Manifest struct { - Name string `json:"name"` - Version string `json:"version"` - Package *Package `json:"package"` - Workflows []ReferencedEntry `json:"workflows"` - Resources []ReferencedEntry `json:"resources"` - Metadata map[string]any `json:"metadata"` -} - -// Package contains optional package metadata fields. -type Package struct { - Name string `json:"name"` - Version string `json:"version"` - Summary string `json:"summary"` - Maintainers []string `json:"maintainers"` -} - -// ReferencedEntry represents an item that points to a file within the package. -type ReferencedEntry struct { - Path string `json:"path"` - Type string `json:"type"` + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Level int `json:"level"` + Version string `json:"version"` + Lifecycle string `json:"lifecycle"` + Cornerstones *Cornerstones `json:"cornerstones"` + Paths *Paths `json:"paths"` +} + +// Cornerstones records which of the four UAPF cornerstones the package declares. +type Cornerstones struct { + BPMN bool `json:"bpmn"` + DMN bool `json:"dmn"` + CMMN bool `json:"cmmn"` + Resources bool `json:"resources"` +} + +// Paths records optional per-cornerstone directory overrides. +type Paths struct { + BPMN string `json:"bpmn"` + DMN string `json:"dmn"` + CMMN string `json:"cmmn"` + Resources string `json:"resources"` + Metadata string `json:"metadata"` +} + +func dirOr(v, def string) string { + if v == "" { + return def + } + return v +} + +// BPMNDir returns the effective bpmn cornerstone directory. +func (m *Manifest) BPMNDir() string { + if m.Paths != nil { + return dirOr(m.Paths.BPMN, "bpmn") + } + return "bpmn" +} + +// DMNDir returns the effective dmn cornerstone directory. +func (m *Manifest) DMNDir() string { + if m.Paths != nil { + return dirOr(m.Paths.DMN, "dmn") + } + return "dmn" +} + +// CMMNDir returns the effective cmmn cornerstone directory. +func (m *Manifest) CMMNDir() string { + if m.Paths != nil { + return dirOr(m.Paths.CMMN, "cmmn") + } + return "cmmn" +} + +// ResourcesDir returns the effective resources cornerstone directory. +func (m *Manifest) ResourcesDir() string { + if m.Paths != nil { + return dirOr(m.Paths.Resources, "resources") + } + return "resources" +} + +// MetadataDir returns the effective metadata directory. +func (m *Manifest) MetadataDir() string { + if m.Paths != nil { + return dirOr(m.Paths.Metadata, "metadata") + } + return "metadata" } diff --git a/modules/uapf/spec/validate.go b/modules/uapf/spec/validate.go index fca679d..206779c 100644 --- a/modules/uapf/spec/validate.go +++ b/modules/uapf/spec/validate.go @@ -5,40 +5,79 @@ package spec import ( "errors" - "path" + "fmt" ) -// ValidateManifest performs lightweight structural checks expected by the UAPF schema -// and returns a normalized list of referenced paths to verify on disk. -func ValidateManifest(manifest *Manifest) ([]string, error) { - if manifest == nil { - return nil, errors.New("manifest is missing") - } +// FileChecker abstracts package-content lookups so the same structural checks +// run against an extracted directory (import) or a git tree (export). +type FileChecker interface { + // DirHasFiles reports whether dir exists and contains at least one file + // whose name ends with suffix (case-insensitive). An empty suffix matches + // any file. + DirHasFiles(dir, suffix string) bool + // FileExists reports whether the given package-relative file exists. + FileExists(path string) bool +} - if manifest.Name == "" || manifest.Version == "" { - if manifest.Package == nil || manifest.Package.Name == "" || manifest.Package.Version == "" { - return nil, errors.New("manifest must include name and version or package.name and package.version") - } +// ValidateManifest performs manifest-internal consistency checks expected by +// UAPF v2.2.0, beyond what the embedded JSON schema already enforces. +func ValidateManifest(m *Manifest) error { + if m == nil { + return errors.New("manifest is missing") } - - refPaths := make([]string, 0, len(manifest.Workflows)+len(manifest.Resources)) - for _, wf := range manifest.Workflows { - if wf.Path == "" { - return nil, errors.New("workflows entry is missing path") - } - refPaths = append(refPaths, cleanRelativePath(wf.Path)) + if m.Kind != "uapf.package" { + return fmt.Errorf("manifest.kind must be \"uapf.package\", got %q", m.Kind) } - for _, res := range manifest.Resources { - if res.Path == "" { - return nil, errors.New("resources entry is missing path") - } - refPaths = append(refPaths, cleanRelativePath(res.Path)) + if m.ID == "" { + return errors.New("manifest.id is required") } - - return refPaths, nil + if m.Name == "" { + return errors.New("manifest.name is required") + } + if m.Version == "" { + return errors.New("manifest.version is required") + } + if m.Level < 0 || m.Level > 4 { + return fmt.Errorf("manifest.level must be between 0 and 4, got %d", m.Level) + } + if m.Cornerstones == nil { + return errors.New("manifest.cornerstones is required") + } + if m.Level >= 4 && !m.Cornerstones.BPMN { + return errors.New("a Level-4 package must declare the bpmn cornerstone") + } + return nil } -func cleanRelativePath(p string) string { - clean := path.Clean("/" + p) - return clean[1:] +// ValidatePackageStructure verifies that the package contents match what the +// manifest declares, following the UAPF v2.2.0 package-format rules. +func ValidatePackageStructure(m *Manifest, fc FileChecker) error { + if m == nil || m.Cornerstones == nil { + return errors.New("manifest is missing required fields") + } + if m.Cornerstones.BPMN && !fc.DirHasFiles(m.BPMNDir(), ".bpmn") { + return fmt.Errorf("bpmn cornerstone declared but no .bpmn file found in %s/", m.BPMNDir()) + } + if m.Cornerstones.DMN && !fc.DirHasFiles(m.DMNDir(), ".dmn") { + return fmt.Errorf("dmn cornerstone declared but no .dmn file found in %s/", m.DMNDir()) + } + if m.Cornerstones.CMMN && !fc.DirHasFiles(m.CMMNDir(), ".cmmn") { + return fmt.Errorf("cmmn cornerstone declared but no .cmmn file found in %s/", m.CMMNDir()) + } + if m.Cornerstones.Resources && !fc.DirHasFiles(m.ResourcesDir(), "") { + return fmt.Errorf("resources cornerstone declared but %s/ is empty", m.ResourcesDir()) + } + if m.Level >= 4 { + mappings := m.ResourcesDir() + "/mappings.yaml" + if !fc.FileExists(mappings) { + return fmt.Errorf("a Level-4 package requires %s", mappings) + } + for _, f := range []string{"ownership.yaml", "lifecycle.yaml"} { + p := m.MetadataDir() + "/" + f + if !fc.FileExists(p) { + return fmt.Errorf("a Level-4 package requires %s", p) + } + } + } + return nil } diff --git a/modules/uapf/validate.go b/modules/uapf/validate.go index 8025822..d3b21fb 100644 --- a/modules/uapf/validate.go +++ b/modules/uapf/validate.go @@ -36,23 +36,12 @@ func loadManifestSchema() (*jsonschema.Schema, error) { return manifestSchema, manifestSchemaErr } -// ValidatePackage ensures a .uapf archive contains a manifest.json that conforms to the embedded schema. -func ValidatePackage(data []byte) error { - readerAt := bytes.NewReader(data) - - zipReader, err := zip.NewReader(readerAt, int64(len(data))) - if err != nil { - return fmt.Errorf("invalid .uapf archive: %w", err) - } - - manifestJSON, err := extractManifest(zipReader) - if err != nil { - return err - } - +// validateAgainstSchema validates normalized JSON manifest bytes against the +// embedded UAPF v2.2.0 manifest schema. +func validateAgainstSchema(jsonData []byte) error { var manifest any - if err := json.Unmarshal(manifestJSON, &manifest); err != nil { - return fmt.Errorf("manifest.json is not valid JSON: %w", err) + if err := json.Unmarshal(jsonData, &manifest); err != nil { + return fmt.Errorf("manifest is not valid JSON: %w", err) } schema, err := loadManifestSchema() @@ -61,55 +50,81 @@ func ValidatePackage(data []byte) error { } if err := schema.Validate(manifest); err != nil { - if validationErr, ok := err.(*jsonschema.ValidationError); ok { - return fmt.Errorf("manifest validation failed: %s", validationErr) + var ve *jsonschema.ValidationError + if errors.As(err, &ve) { + return fmt.Errorf("manifest validation failed: %s", ve) } return fmt.Errorf("manifest validation failed: %w", err) } - return nil } -func extractManifest(zipReader *zip.Reader) ([]byte, error) { +// ValidatePackage ensures a .uapf archive contains a UAPF manifest (uapf.yaml) +// that conforms to the embedded UAPF v2.2.0 schema. +func ValidatePackage(data []byte) error { + zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return fmt.Errorf("invalid .uapf archive: %w", err) + } + + name, raw, err := extractManifest(zipReader) + if err != nil { + return err + } + + jsonData, err := manifestToJSON(name, raw) + if err != nil { + return err + } + + return validateAgainstSchema(jsonData) +} + +// extractManifest returns the highest-priority manifest file found anywhere in +// the archive: its base name and raw bytes. +func extractManifest(zipReader *zip.Reader) (string, []byte, error) { + bestName := "" + bestPriority := len(ManifestNames) + var bestFile *zip.File + for _, file := range zipReader.File { - name := filepath.Clean(file.Name) - if filepath.Base(name) != "manifest.json" { + if file.FileInfo().IsDir() { continue } - - manifestReader, err := file.Open() - if err != nil { - return nil, fmt.Errorf("open manifest.json: %w", err) + base := filepath.Base(filepath.Clean(file.Name)) + if !IsManifestName(base) { + continue } - defer manifestReader.Close() - - contents, err := io.ReadAll(manifestReader) - if err != nil { - return nil, fmt.Errorf("read manifest.json: %w", err) + if p := manifestPriority(base); p < bestPriority { + bestPriority = p + bestName = base + bestFile = file } - return contents, nil } - return nil, errors.New("manifest.json is required in the UAPF package") -} + if bestFile == nil { + return "", nil, errors.New("a UAPF manifest (uapf.yaml) is required in the package") + } -// ValidateManifest validates manifest.json contents against the embedded schema. -func ValidateManifest(data []byte) error { - var manifest any - if err := json.Unmarshal(data, &manifest); err != nil { - return fmt.Errorf("manifest.json is not valid JSON: %w", err) + rc, err := bestFile.Open() + if err != nil { + return "", nil, fmt.Errorf("open %s: %w", bestName, err) } + defer rc.Close() - schema, err := loadManifestSchema() + contents, err := io.ReadAll(rc) if err != nil { - return fmt.Errorf("load manifest schema: %w", err) + return "", nil, fmt.Errorf("read %s: %w", bestName, err) } + return bestName, contents, nil +} - if err := schema.Validate(manifest); err != nil { - if validationErr, ok := err.(*jsonschema.ValidationError); ok { - return fmt.Errorf("manifest validation failed: %s", validationErr) - } - return fmt.Errorf("manifest validation failed: %w", err) +// ValidateManifest validates a manifest file's raw bytes (YAML or JSON, +// selected by name) against the embedded UAPF v2.2.0 schema. +func ValidateManifest(name string, data []byte) error { + jsonData, err := manifestToJSON(name, data) + if err != nil { + return err } - return nil + return validateAgainstSchema(jsonData) } diff --git a/package.json b/package.json index f89b447..8d62026 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "htmx.org": "2.0.8", "idiomorph": "0.7.4", "jquery": "3.7.1", + "js-yaml": "^4.1.0", "katex": "0.16.27", "mermaid": "11.12.2", "mini-css-extract-plugin": "2.9.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e2829b..4292d62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: jquery: specifier: 3.7.1 version: 3.7.1 + js-yaml: + specifier: ^4.1.0 + version: 4.1.1 katex: specifier: 0.16.27 version: 0.16.27 diff --git a/resources/uapf/schemas/uapf-manifest.schema.json b/resources/uapf/schemas/uapf-manifest.schema.json index 3b092d8..9f80c83 100644 --- a/resources/uapf/schemas/uapf-manifest.schema.json +++ b/resources/uapf/schemas/uapf-manifest.schema.json @@ -1,76 +1,132 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "UAPF manifest", - "description": "Validation schema for UAPF manifest.json files.", + "$id": "https://uapf.dev/schemas/uapf-manifest.schema.json", + "title": "UAPF Manifest (uapf.yaml) Schema", "type": "object", - "additionalProperties": true, + "additionalProperties": false, + "required": ["id", "name", "level", "version", "kind"], "properties": { - "name": { - "type": "string", - "minLength": 1 - }, + "kind": { "type": "string", "enum": ["uapf.package"] }, + "id": { "type": "string", "minLength": 3, "pattern": "^[a-z0-9][a-z0-9._-]+$" }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "level": { "type": "integer", "minimum": 0, "maximum": 4 }, "version": { "type": "string", - "minLength": 1 + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$" + }, + "includes": { "type": "array", "items": { "$ref": "#/$defs/packageRef" }, "default": [] }, + "dependencies": { + "type": "object", + "description": "External package dependencies with version constraints", + "additionalProperties": { + "type": "string", + "pattern": "^(\\^|~|>=|<=|>|<|=)?[0-9]+(\\.[0-9]+)*(\\.[0-9]+)?(-[a-zA-Z0-9.-]+)?(,\\s*(>=|<=|>|<|=)?[0-9]+(\\.[0-9]+)*(\\.[0-9]+)?(-[a-zA-Z0-9.-]+)?)*$|^latest$" + } + }, + "cornerstones": { + "type": "object", + "additionalProperties": false, + "required": ["bpmn", "dmn", "cmmn", "resources"], + "properties": { + "bpmn": { "type": "boolean" }, + "dmn": { "type": "boolean" }, + "cmmn": { "type": "boolean" }, + "resources": { "type": "boolean" } + } }, - "description": { - "type": "string" + "paths": { + "type": "object", + "additionalProperties": false, + "properties": { + "bpmn": { "type": "string", "default": "bpmn" }, + "dmn": { "type": "string", "default": "dmn" }, + "cmmn": { "type": "string", "default": "cmmn" }, + "resources": { "type": "string", "default": "resources" }, + "metadata": { "type": "string", "default": "metadata" } + } }, - "package": { + "exposure": { "type": "object", - "additionalProperties": true, + "description": "Declarative exposure rules for external bindings such as MCP", + "additionalProperties": false, "properties": { - "name": { - "type": "string", - "minLength": 1 - }, - "version": { - "type": "string", - "minLength": 1 - }, - "summary": { - "type": "string" - }, - "maintainers": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 + "mcp": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": true }, + "runnable": { "type": "boolean" }, + "exposedEntrypoints": { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["tool"], + "properties": { + "decision": { "type": "string" }, + "process": { "type": "string" }, + "tool": { "type": "string", "minLength": 1 } + } + } + ] + } + }, + "exposedArtifacts": { + "type": "array", + "items": { "enum": ["manifest", "bpmn", "dmn", "cmmn", "docs", "tests"] } + } } } - }, - "required": [ - "name", - "version" - ] - }, - "workflows": { - "type": "array", - "items": { - "type": "object" } }, - "resources": { - "type": "array", - "items": { - "type": "object" + "owners": { "type": "array", "items": { "$ref": "#/$defs/owner" }, "minItems": 1 }, + "lifecycle": { "type": "string", "enum": ["draft", "review", "approved", "deprecated"] }, + "artifacts": { + "type": "object", + "description": "Optional per-cornerstone artifact inventory", + "additionalProperties": false, + "properties": { + "bpmn": { "type": "array", "items": { "$ref": "#/$defs/artifactEntry" } }, + "dmn": { "type": "array", "items": { "$ref": "#/$defs/artifactEntry" } }, + "cmmn": { "type": "array", "items": { "$ref": "#/$defs/artifactEntry" } }, + "resources": { "type": "array", "items": { "$ref": "#/$defs/artifactEntry" } } } }, - "metadata": { - "type": "object" - } + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } }, + "requires_capabilities": { "type": "array", "items": { "type": "string" } }, + "profiles_supported": { "type": "array", "items": { "type": "string" } }, + "guardrails": { "type": "string" } }, - "anyOf": [ - { - "required": [ - "name", - "version" - ] + "$defs": { + "packageRef": { + "type": "string", + "minLength": 1, + "pattern": "^(\\.\\.?/|package:)[^\\s]+$" + }, + "owner": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id"], + "properties": { + "type": { "type": "string", "enum": ["team", "person", "role"] }, + "id": { "type": "string", "minLength": 1 }, + "contact": { "type": "string" } + } }, - { - "required": [ - "package" - ] + "artifactEntry": { + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "role": { "type": "string" }, + "description": { "type": "string" } + } } - ] + } } diff --git a/routers/web/repo/uapfmcp.go b/routers/web/repo/uapfmcp.go new file mode 100644 index 0000000..9e01795 --- /dev/null +++ b/routers/web/repo/uapfmcp.go @@ -0,0 +1,149 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + stdctx "context" + "net/http" + "os" + "strings" + "sync" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + + uapfengine "github.com/UAPFormat/uapf-mcp-go/engine" + uapfmcp "github.com/UAPFormat/uapf-mcp-go/mcp" + "github.com/UAPFormat/uapf-mcp-go/pkgsource" + + "gopkg.in/yaml.v3" +) + +// UAPFMCPEndpoint serves the per-repo UAPF *execution* MCP scope. +// +// Where /mcp exposes the repo's content as read/reason tools and /uapf-ip +// serves a static package descriptor, /uapf-mcp exposes the repo as a RUNNABLE +// package: an agent (e.g. Copilot Studio) connects here and gets execution +// tools (start_session, execute_process, evaluate_decision, get_audit, ...) +// that ProcessGit delegates to the UAPF engine. ProcessGit terminates the MCP +// protocol and forwards execution to the engine; it does not itself hold +// sessions or evaluate decisions — the runtime is the engine. +// +// The package is resolved live from the repo's uapf.yaml manifest on each +// connection, so a push updates the executable surface with no redeploy. +func UAPFMCPEndpoint(ctx *context.Context) { + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + if git.IsErrNotExist(err) { + ctx.JSON(http.StatusNotFound, map[string]string{"error": "repository is empty"}) + } else { + ctx.ServerError("GetBranchCommit", err) + } + return + } + + manifest, _, wrote := readUAPFManifest(ctx, commit) + if wrote { + return + } + if manifest == nil { + ctx.JSON(http.StatusNotFound, map[string]string{ + "error": "not a UAPF package repository (no uapf.yaml / process.uapf.yaml in root)", + }) + return + } + + repoPath := ctx.Repo.Repository.FullName() + branch := ctx.Repo.Repository.DefaultBranch + archiveBase := strings.TrimSuffix(envOr("UAPF_ARCHIVE_BASE", strings.TrimSuffix(setting.AppURL, "/")), "/") + + pkg := &pkgsource.Package{ + Ref: repoPath, + ID: manifest.ID, + Name: manifest.Name, + Version: manifest.Version, + Level: manifest.Level, + Kind: manifest.Kind, + ArchiveURL: archiveBase + "/" + repoPath + "/archive/" + branch + ".zip", + RequiresCapabilities: manifest.RequiresCapabilities, + Guardrails: manifest.Guardrails, + } + + eng, host := uapfRuntime() + srv := uapfmcp.NewServer(staticUAPFProvider{pkg: pkg}, eng) + srv.Host = host + srv.HandleMCP(ctx.Resp, ctx.Req, repoPath) +} + +// readUAPFManifest reads the repo's UAPF manifest from the given commit. +// Returns (manifest, filename, responseWritten). On a malformed manifest it +// writes a 422 and returns responseWritten=true. A nil manifest with +// responseWritten=false means no manifest is present in the repo root. +func readUAPFManifest(ctx *context.Context, commit *git.Commit) (*uapfManifest, string, bool) { + for _, name := range uapfManifestFiles { + entry, err := commit.GetTreeEntryByPath(name) + if err != nil { + if git.IsErrNotExist(err) { + continue + } + ctx.ServerError("GetTreeEntryByPath", err) + return nil, "", true + } + if entry.IsDir() || entry.Blob().Size() > maxUAPFManifestSize { + continue + } + reader, err := entry.Blob().DataAsync() + if err != nil { + ctx.ServerError("Blob.DataAsync", err) + return nil, "", true + } + var m uapfManifest + decErr := yaml.NewDecoder(reader).Decode(&m) + _ = reader.Close() + if decErr != nil { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "error": "invalid UAPF manifest " + name + ": " + decErr.Error(), + }) + return nil, "", true + } + return &m, name, false + } + return nil, "", false +} + +// staticUAPFProvider implements pkgsource.Provider for a single, pre-resolved +// package (read live from the repo's git tree in the handler). +type staticUAPFProvider struct{ pkg *pkgsource.Package } + +func (p staticUAPFProvider) Resolve(_ stdctx.Context, _ string) (*pkgsource.Package, error) { + return p.pkg, nil +} + +var ( + uapfRuntimeOnce sync.Once + uapfEngineCli *uapfengine.Client + uapfHostCfg uapfmcp.HostConfig +) + +// uapfRuntime lazily builds the shared engine client + capability-host config +// from environment (wired to the bundled engine + LLM gateway in the release). +func uapfRuntime() (*uapfengine.Client, uapfmcp.HostConfig) { + uapfRuntimeOnce.Do(func() { + uapfEngineCli = uapfengine.New(envOr("UAPF_ENGINE_URL", "http://uapf-engine:4000")) + uapfHostCfg = uapfmcp.HostConfig{ + HostDID: envOr("UAPF_HOST_DID", "did:web:processgit.local"), + HostBaseURL: os.Getenv("UAPF_HOST_BASE_URL"), + Profiles: []string{"uapf-ip-orchestrated"}, + } + }) + return uapfEngineCli, uapfHostCfg +} + +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} diff --git a/routers/web/web.go b/routers/web/web.go index 85ec00f..39c2a67 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1194,6 +1194,11 @@ func registerWebRoutes(m *web.Router) { m.Methods("GET, OPTIONS", "", repo.UAPFIPEndpoint) }, optSignInIgnoreCsrf, context.RepoAssignment) + // UAPF-MCP endpoint — per-repo execution MCP scope (runs the package on the engine) + m.Group("/{username}/{reponame}/uapf-mcp", func() { + m.Methods("GET, POST, DELETE, OPTIONS", "", repo.UAPFMCPEndpoint) + }, optSignInIgnoreCsrf, context.RepoAssignment) + // Chat agent endpoints — AI chatbot interface for repositories m.Group("/{username}/{reponame}/chat", func() { m.Post("", repo.ChatEndpoint) diff --git a/templates/home.tmpl b/templates/home.tmpl index ff8c0d3..d277fb6 100644 --- a/templates/home.tmpl +++ b/templates/home.tmpl @@ -105,7 +105,7 @@

Learn more at uapf.dev → -
my-process.uapf/
  manifest.json
  models/
    bpmn/main.bpmn
    dmn/decisions.dmn
    cmmn/case.cmmn
  tests/
    scenarios/*.json
  docs/
    overview.md
+
my-process.uapf/
  uapf.yaml
  models/
    bpmn/main.bpmn
    dmn/decisions.dmn
    cmmn/case.cmmn
  tests/
    scenarios/*.json
  docs/
    overview.md
diff --git a/templates/repo/empty.tmpl b/templates/repo/empty.tmpl index fd3cf2b..bc10d77 100644 --- a/templates/repo/empty.tmpl +++ b/templates/repo/empty.tmpl @@ -27,6 +27,7 @@
{{if and .CanWriteCode (not .Repository.IsArchived)}} + {{svg "octicon-file-zip" 16 "tw-mr-1"}}Import .uapf {{ctx.Locale.Tr "repo.editor.new_file"}} diff --git a/templates/repo/uapf_import_modal.tmpl b/templates/repo/uapf_import_modal.tmpl index c8321b2..a08a581 100644 --- a/templates/repo/uapf_import_modal.tmpl +++ b/templates/repo/uapf_import_modal.tmpl @@ -16,7 +16,7 @@
- The uploaded package will be validated against UAPF v1 schemas before it is committed. + The uploaded package is validated against the UAPF v2.2.0 manifest schema before it is committed. The manifest may be uapf.yaml (preferred) or manifest.json.