From 92b6c1892ee547accaa56bfc303ae61042517864 Mon Sep 17 00:00:00 2001
From: Rihards Gailums
Date: Mon, 18 May 2026 08:40:45 +0000
Subject: [PATCH 1/6] uapf: align import/export with UAPF v2.2.0 manifest
- accept uapf.yaml / uapf.yml / process.uapf.* (legacy manifest.json still
accepted); decode YAML manifests, validate as the v2.2.0 schema
- replace embedded schema with the canonical UAPF v2.2.0 manifest schema
- spec.Manifest now models kind/id/level/version/cornerstones/paths
- cornerstone-aware structural validation (Level-4 requires bpmn cornerstone
+ resources/mappings.yaml + metadata/ownership.yaml + metadata/lifecycle.yaml)
- empty-repo screen: add an Import .uapf button (modal already in repo header);
repo-uapf-import.ts binds the trigger independently of the dropdown
- refresh import-modal copy and home-page tree illustration to uapf.yaml
---
modules/uapf/exporter.go | 80 ++++-----
modules/uapf/importer.go | 99 +++++++----
modules/uapf/locate.go | 71 ++++++++
modules/uapf/spec/manifest.go | 98 +++++++---
modules/uapf/spec/validate.go | 95 +++++++---
modules/uapf/validate.go | 109 +++++++-----
.../uapf/schemas/uapf-manifest.schema.json | 168 ++++++++++++------
templates/home.tmpl | 2 +-
templates/repo/empty.tmpl | 1 +
templates/repo/uapf_import_modal.tmpl | 2 +-
web_src/js/features/repo-uapf-import.ts | 10 +-
11 files changed, 492 insertions(+), 243 deletions(-)
create mode 100644 modules/uapf/locate.go
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/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/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 @@
- 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.
diff --git a/web_src/js/features/repo-uapf-import.ts b/web_src/js/features/repo-uapf-import.ts
index 86afd28..7ef79de 100644
--- a/web_src/js/features/repo-uapf-import.ts
+++ b/web_src/js/features/repo-uapf-import.ts
@@ -1,12 +1,14 @@
import $ from 'jquery';
export function initRepoUAPFImport() {
- const $dropdown = $('#uapf-import-dropdown');
- if (!$dropdown.length) return;
+ const $open = $('#uapf-import-open');
+ if (!$open.length) return;
- $dropdown.dropdown();
+ // The dropdown only exists on the populated repo view; .dropdown() on an
+ // empty set is a harmless no-op, so the trigger also works on the empty repo.
+ $('#uapf-import-dropdown').dropdown();
- $('#uapf-import-open').on('click', (event) => {
+ $open.on('click', (event) => {
event.preventDefault();
$('#uapf-import-modal').modal('show');
});
From dea33fe3e53b7234aae5fb85e56adcc91c0c8d52 Mon Sep 17 00:00:00 2001
From: Rihards Gailums
Date: Thu, 21 May 2026 08:58:52 +0000
Subject: [PATCH 2/6] feat: UAPF v2.5.0 Algorithm Card viewer + BPMN overlay
(catch-up commit)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Catches up the ProcessGit working tree with all UAPF integration work:
Phase 3 (UAPF v2.4.0 — BPMN algorithm task overlay; deployed earlier):
- web_src/js/features/diagrams/bpmn.ts: wire attachUapfAlgorithmOverlays
into BpmnViewer and BpmnModeler import flows.
- web_src/js/features/diagrams/uapf-algorithm-overlay.ts (new): bpmn-js
Overlays-API based renderer; for every serviceTask carrying
uapf:algorithmCardRef (any prefix bound to https://uapf.dev/bpmn/v2.4)
draws the stacked-cards + ƒ icon (top-left), risk-class dot (top-right,
green/amber/red per chapter 13.10), card id label, and metadata strip
(version · kind · determinism).
- web_src/js/features/diagrams/uapf-card-loader.ts (new): fetches
algorithms/{cardId}.card.yaml via the Gitea raw endpoint; minimal
regex-based YAML scalar parser; per-page cache.
Phase B (UAPF v2.5.0 — Algorithm Card viewer for *.card.yaml):
- modules/diagrams/detect.go: new DiagramCard type, extension detection
for *.card.yaml/.yml/.json, Editable() true so Edit File button works
(the adapter routes Edit to Gitea's native editor).
- web_src/js/features/diagrams/index.ts: case 'card' added to
createAdapter switch.
- web_src/js/features/diagrams/card.ts (new, 518 lines): polymorphic
Preview-tab adapter for algorithm cards. Common header (metadata
strip + IO contract + risk dot per chapter 13.10) + polymorphic body
(external sample browser with string-equality match against embedded
tests per Q3a, inline regex highlighter, inline FEEL placeholder for
future client-side eval, inline DMN link-out to cornerstone,
composite step list, fallback for rego/sql/wasm) + footer (test count
+ chapter 13.16 reference). enterEdit redirects to Gitea's built-in
editor at /_edit/{branch}/{path}.
- package.json + pnpm-lock.yaml: js-yaml ^4.1.0 added.
Deployment note: as of this commit the running processgit binary at
/app/gitea/gitea does NOT include the detect.go change (Go rebuild not
performed in the working session). The card viewer is live via a
tactical frontend-only hook (custom footer.tmpl + esbuild-bundled
standalone uapf-card-viewer-standalone.js dropped into
/data/gitea/public/) that detects *.card.yaml URLs client-side. To
land the proper Preview-tab integration, rebuild the processgit Docker
image from this branch's HEAD; the Dockerfile.binonly chmod step
expects the binary name 'gitea' (legacy) but Makefile produces
'processgit' (fork name) — patch chmod or rename binary before image
deploys cleanly.
---
modules/diagrams/detect.go | 9 +-
package.json | 1 +
pnpm-lock.yaml | 3 +
web_src/js/features/diagrams/bpmn.ts | 3 +
web_src/js/features/diagrams/card.ts | 518 ++++++++++++++++++
web_src/js/features/diagrams/index.ts | 4 +
.../diagrams/uapf-algorithm-overlay.ts | 170 ++++++
.../js/features/diagrams/uapf-card-loader.ts | 140 +++++
8 files changed, 847 insertions(+), 1 deletion(-)
create mode 100644 web_src/js/features/diagrams/card.ts
create mode 100644 web_src/js/features/diagrams/uapf-algorithm-overlay.ts
create mode 100644 web_src/js/features/diagrams/uapf-card-loader.ts
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/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/web_src/js/features/diagrams/bpmn.ts b/web_src/js/features/diagrams/bpmn.ts
index 057688a..2693804 100644
--- a/web_src/js/features/diagrams/bpmn.ts
+++ b/web_src/js/features/diagrams/bpmn.ts
@@ -11,6 +11,7 @@ import {BpmnPropertiesPanelModule, BpmnPropertiesProviderModule} from 'bpmn-js-p
// @ts-ignore - package ships without types
import * as AutoLayoutPkg from 'bpmn-auto-layout';
import type {DiagramAdapter} from './types.ts';
+import {attachUapfAlgorithmOverlays} from './uapf-algorithm-overlay.ts';
import 'bpmn-js/dist/assets/diagram-js.css';
import 'bpmn-js/dist/assets/bpmn-js.css';
@@ -103,6 +104,7 @@ export function createBpmnAdapter(canvas: HTMLElement, properties?: HTMLElement
viewer = new BpmnViewer({container: canvas});
const preparedXml = await prepareBpmnXml(xml);
await viewer.importXML(preparedXml);
+ attachUapfAlgorithmOverlays(viewer);
viewer.get('canvas')?.zoom('fit-viewport');
},
@@ -118,6 +120,7 @@ export function createBpmnAdapter(canvas: HTMLElement, properties?: HTMLElement
});
const preparedXml = await prepareBpmnXml(xml);
await modeler.importXML(preparedXml);
+ attachUapfAlgorithmOverlays(modeler);
modeler.get('canvas')?.zoom('fit-viewport');
propertiesPanelParent?.classList.remove('tw-hidden');
bindChangeHandler();
diff --git a/web_src/js/features/diagrams/card.ts b/web_src/js/features/diagrams/card.ts
new file mode 100644
index 0000000..0e171c4
--- /dev/null
+++ b/web_src/js/features/diagrams/card.ts
@@ -0,0 +1,518 @@
+// UAPF v2.5.0+ Algorithm Card viewer.
+//
+// Renders the Preview tab for *.card.yaml / *.card.yml / *.card.json files.
+// Polymorphic on the card's `implementation.type`:
+// - external → sample browser (string-equality match against embedded tests)
+// - inline → per-language visualiser (regex highlight, FEEL evaluator,
+// dmn link-out to cornerstone, other languages fall back to
+// syntax-highlighted source + sample browser)
+// - composite → call-tree (v1.5 — currently falls back to summary)
+//
+// Common across all: metadata strip (kind / determinism / risk dot per
+// UAPF chapter 13.10) + IO contract panel + sample browser footer.
+//
+// "Edit File" routes to Gitea's built-in editor (no in-page editor here).
+// "View Source" uses the existing raw view.
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore - js-yaml ships without types in some contexts
+import yaml from 'js-yaml';
+import type {DiagramAdapter} from './types.ts';
+
+interface AlgorithmCard {
+ kind?: string;
+ id: string;
+ name?: string;
+ version?: string;
+ intent?: string;
+ description?: string;
+ algorithm_kind?: string;
+ determinism?: 'deterministic' | 'stochastic' | 'learned';
+ io?: {
+ inputs?: Array<{id: string; type: string; description?: string; unit?: string}>;
+ outputs?: Array<{id: string; type: string; description?: string; unit?: string}>;
+ };
+ risk?: {
+ aiActRiskClass?: string;
+ humanOversight?: string;
+ transparencyTier?: string;
+ };
+ implementation?: {
+ type?: 'inline' | 'external' | 'composite';
+ medium?: string; // for external
+ language?: string; // for inline
+ uri?: string;
+ hash?: string;
+ runtime?: Record;
+ inline?: {language?: string; source?: string};
+ external?: {medium?: string; uri?: string};
+ composite?: {composed_of?: Array<{id: string; description?: string}>};
+ };
+ tests?: Array<{
+ name: string;
+ description?: string;
+ inputs: Record;
+ expected_outputs: Record;
+ tolerance?: Record;
+ }>;
+ lifecycle?: {status?: string; since?: string};
+ [k: string]: unknown;
+}
+
+// ── Risk-class derivation (UAPF chapter 13.10) ────────────────────
+function riskClassFor(card: AlgorithmCard): 'green' | 'amber' | 'red' | 'unknown' {
+ const ai = card.risk?.aiActRiskClass;
+ const ov = card.risk?.humanOversight;
+ const det = card.determinism;
+ if (ai === 'high' || ai === 'unacceptable' || ov === 'mandatory') return 'red';
+ if (det === 'stochastic' || det === 'learned') return 'amber';
+ if (ai === 'limited' && ov && ov !== 'none') return 'amber';
+ if (det === 'deterministic') return 'green';
+ return 'unknown';
+}
+
+const RISK_DOT_COLOR: Record = {
+ green: '#1D9E75',
+ amber: '#EF9F27',
+ red: '#E24B4A',
+ unknown: '#888780',
+};
+
+const RISK_LABEL: Record = {
+ green: 'low risk',
+ amber: 'attention',
+ red: 'high risk',
+ unknown: 'unknown',
+};
+
+// ── Tiny DOM helpers (no framework dependency) ────────────────────
+function el(tag: string, attrs: Record = {}, ...children: (Node | string | null)[]): HTMLElement {
+ const e = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs)) {
+ if (k === 'class') e.className = v;
+ else if (k === 'style') e.setAttribute('style', v);
+ else if (k.startsWith('data-')) e.setAttribute(k, v);
+ else e.setAttribute(k, v);
+ }
+ for (const c of children) {
+ if (c == null) continue;
+ e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
+ }
+ return e;
+}
+
+function clear(container: HTMLElement): void {
+ container.innerHTML = '';
+}
+
+function fmtValue(v: unknown): string {
+ if (v === null || v === undefined) return '';
+ if (typeof v === 'object') return JSON.stringify(v, null, 2);
+ return String(v);
+}
+
+function deepEqual(a: unknown, b: unknown): boolean {
+ if (a === b) return true;
+ if (typeof a !== typeof b) return false;
+ if (a == null || b == null) return false;
+ if (typeof a !== 'object') return false;
+ const aa = a as Record;
+ const bb = b as Record;
+ const aKeys = Object.keys(aa);
+ const bKeys = Object.keys(bb);
+ if (aKeys.length !== bKeys.length) return false;
+ for (const k of aKeys) if (!deepEqual(aa[k], bb[k])) return false;
+ return true;
+}
+
+// ── Common header: metadata strip ─────────────────────────────────
+function renderHeader(card: AlgorithmCard): HTMLElement {
+ const risk = riskClassFor(card);
+ const det = card.determinism || 'deterministic';
+ const kind = card.algorithm_kind || 'algorithm';
+ const impl = card.implementation || {};
+ const implLabel = impl.type === 'external'
+ ? `external · ${impl.medium || 'unknown'}`
+ : impl.type === 'inline'
+ ? `inline · ${(impl.inline && impl.inline.language) || impl.language || 'unknown'}`
+ : impl.type === 'composite'
+ ? `composite · ${(impl.composite?.composed_of || []).length} step(s)`
+ : 'unknown';
+
+ return el('div', {class: 'uapf-card-header', style: 'padding:16px 20px;background:#F8F7F4;border-bottom:1px solid #E5E2DC;'},
+ // top row: id + risk dot
+ el('div', {style: 'display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;'},
+ el('div', {style: 'font-family:\"Source Code Pro\",monospace;font-size:13px;color:#3D3929;font-weight:500;'}, card.id),
+ el('div', {style: 'display:flex;align-items:center;gap:6px;font-size:11px;color:#7F7B6E;'},
+ el('span', {style: `width:10px;height:10px;border-radius:50%;background:${RISK_DOT_COLOR[risk]};display:inline-block;`}),
+ document.createTextNode(RISK_LABEL[risk]),
+ ),
+ ),
+ // name
+ card.name ? el('div', {style: 'font-size:18px;font-weight:600;color:#1F2328;margin-bottom:4px;'}, card.name) : null,
+ // intent / description
+ (card.intent || card.description)
+ ? el('div', {style: 'font-size:13px;color:#5F5E5A;margin-bottom:10px;line-height:1.5;max-width:760px;'}, card.intent || card.description || '')
+ : null,
+ // metadata strip
+ el('div', {style: 'display:flex;flex-wrap:wrap;gap:10px;font-size:12px;color:#5F5E5A;'},
+ pill('v' + (card.version || '?')),
+ pill(kind),
+ pill(det),
+ pill(implLabel),
+ card.lifecycle?.status ? pill(card.lifecycle.status) : null,
+ ),
+ );
+}
+
+function pill(text: string | null): HTMLElement | null {
+ if (!text) return null;
+ return el('span', {style: 'background:#FFFFFF;border:1px solid #E5E2DC;border-radius:3px;padding:2px 8px;font-family:\"Source Code Pro\",monospace;font-size:11px;'}, text);
+}
+
+// ── Common header: IO contract panel ──────────────────────────────
+function renderIOContract(card: AlgorithmCard): HTMLElement {
+ const inputs = card.io?.inputs || [];
+ const outputs = card.io?.outputs || [];
+ return el('div', {style: 'padding:16px 20px;border-bottom:1px solid #E5E2DC;'},
+ el('div', {style: 'font-size:11px;color:#888780;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;'}, 'IO contract'),
+ el('div', {style: 'display:grid;grid-template-columns:1fr 1fr;gap:24px;'},
+ // inputs
+ el('div', {},
+ el('div', {style: 'font-size:11px;color:#5F5E5A;margin-bottom:6px;'}, `inputs (${inputs.length})`),
+ ...inputs.map(f =>
+ el('div', {style: 'display:flex;justify-content:space-between;font-size:12px;padding:4px 8px;background:#F8F7F4;border-radius:3px;margin-bottom:2px;font-family:\"Source Code Pro\",monospace;'},
+ el('span', {style: 'color:#3D3929;'}, f.id),
+ el('span', {style: 'color:#888780;'}, `${f.type}${f.unit ? ' (' + f.unit + ')' : ''}`),
+ ),
+ ),
+ ),
+ // outputs
+ el('div', {},
+ el('div', {style: 'font-size:11px;color:#5F5E5A;margin-bottom:6px;'}, `outputs (${outputs.length})`),
+ ...outputs.map(f =>
+ el('div', {style: 'display:flex;justify-content:space-between;font-size:12px;padding:4px 8px;background:#F8F7F4;border-radius:3px;margin-bottom:2px;font-family:\"Source Code Pro\",monospace;'},
+ el('span', {style: 'color:#3D3929;'}, f.id),
+ el('span', {style: 'color:#888780;'}, `${f.type}${f.unit ? ' (' + f.unit + ')' : ''}`),
+ ),
+ ),
+ ),
+ ),
+ );
+}
+
+// ── External sample browser (chapter 13.16, option a) ─────────────
+//
+// User edits the input fields. On each change we look for a test case
+// whose `inputs` match exactly. If found, show the expected_outputs.
+// Otherwise show "no recorded sample" + dropdown of all test cases.
+function renderExternalSampleBrowser(card: AlgorithmCard): HTMLElement {
+ const tests = card.tests || [];
+ const inputs = card.io?.inputs || [];
+ const outputs = card.io?.outputs || [];
+
+ // State: currently selected test index, current input values
+ let selectedIdx = 0;
+ let currentInputs: Record = {...(tests[0]?.inputs || {})};
+
+ const root = el('div', {style: 'padding:16px 20px;'});
+
+ // Section header + test selector
+ const header = el('div', {style: 'display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;'},
+ el('div', {style: 'font-size:11px;color:#888780;text-transform:uppercase;letter-spacing:0.05em;'}, `sample browser (${tests.length} recorded)`),
+ el('div', {style: 'font-size:11px;color:#888780;'}, 'external · black box · input → recorded output'),
+ );
+ root.appendChild(header);
+
+ if (tests.length === 0) {
+ root.appendChild(el('div', {style: 'padding:16px;background:#FEF8E7;border:1px solid #EBD995;border-radius:3px;color:#7A6112;font-size:13px;'},
+ 'This card has no embedded tests. SEM-014 violation (UAPF v2.5.0 chapter 11.3.6 requires ≥2 tests).'));
+ return root;
+ }
+
+ // Test-case selector tabs
+ const tabsRow = el('div', {style: 'display:flex;gap:4px;margin-bottom:12px;flex-wrap:wrap;'});
+ const tabButtons: HTMLElement[] = [];
+ tests.forEach((t, i) => {
+ const btn = el('button', {
+ type: 'button',
+ style: `padding:6px 12px;border:1px solid #E5E2DC;background:${i === 0 ? '#3D3929' : '#FFFFFF'};color:${i === 0 ? '#FFFFFF' : '#3D3929'};border-radius:3px;font-size:12px;cursor:pointer;`,
+ }, t.name);
+ btn.addEventListener('click', () => selectTest(i));
+ tabButtons.push(btn);
+ tabsRow.appendChild(btn);
+ });
+ root.appendChild(tabsRow);
+
+ // Two-column layout: inputs editor on left, outputs panel on right
+ const grid = el('div', {style: 'display:grid;grid-template-columns:1fr 1fr;gap:16px;'});
+ const inputsCol = el('div', {},
+ el('div', {style: 'font-size:11px;color:#5F5E5A;margin-bottom:6px;'}, 'inputs (editable)'),
+ );
+ const outputsCol = el('div', {},
+ el('div', {style: 'font-size:11px;color:#5F5E5A;margin-bottom:6px;'}, 'expected outputs'),
+ );
+ grid.appendChild(inputsCol);
+ grid.appendChild(outputsCol);
+ root.appendChild(grid);
+
+ // Test description display
+ const descBox = el('div', {style: 'margin-top:12px;padding:10px;background:#F8F7F4;border-left:3px solid #C5C3BB;font-size:12px;color:#5F5E5A;line-height:1.5;'});
+ root.appendChild(descBox);
+
+ // Input editor (one row per io.input)
+ const inputElems = new Map();
+ inputs.forEach(f => {
+ const inputId = `uapf-input-${f.id}`;
+ const wrap = el('div', {style: 'margin-bottom:8px;'},
+ el('label', {for: inputId, style: 'display:block;font-family:\"Source Code Pro\",monospace;font-size:11px;color:#3D3929;margin-bottom:2px;'},
+ f.id, el('span', {style: 'color:#888780;font-weight:normal;margin-left:6px;'}, f.type)),
+ );
+ const isLong = f.type === 'string' && (typeof currentInputs[f.id] === 'string' && (currentInputs[f.id] as string).length > 60);
+ const textarea = el('textarea', {
+ id: inputId,
+ style: `width:100%;min-height:${isLong ? '80px' : '38px'};padding:6px 8px;border:1px solid #C5C3BB;border-radius:3px;font-family:\"Source Code Pro\",monospace;font-size:12px;color:#1F2328;resize:vertical;background:#FFFFFF;`,
+ }) as HTMLTextAreaElement;
+ inputElems.set(f.id, textarea);
+ textarea.addEventListener('input', onInputChange);
+ wrap.appendChild(textarea);
+ inputsCol.appendChild(wrap);
+ });
+
+ // Outputs panel
+ const outputBox = el('div', {style: 'background:#F8F7F4;padding:12px;border-radius:3px;font-family:\"Source Code Pro\",monospace;font-size:12px;color:#3D3929;line-height:1.6;min-height:120px;white-space:pre-wrap;word-break:break-word;'});
+ outputsCol.appendChild(outputBox);
+
+ // Match-status badge
+ const matchBadge = el('div', {style: 'margin-top:10px;font-size:11px;'});
+ outputsCol.appendChild(matchBadge);
+
+ function selectTest(i: number) {
+ selectedIdx = i;
+ tabButtons.forEach((b, j) => {
+ b.style.background = j === i ? '#3D3929' : '#FFFFFF';
+ b.style.color = j === i ? '#FFFFFF' : '#3D3929';
+ });
+ const t = tests[i];
+ currentInputs = {...t.inputs};
+ inputs.forEach(f => {
+ const ta = inputElems.get(f.id);
+ if (ta) {
+ const v = currentInputs[f.id];
+ ta.value = (typeof v === 'object' && v !== null) ? JSON.stringify(v, null, 2) : fmtValue(v);
+ }
+ });
+ descBox.textContent = t.description || '(no description)';
+ renderOutputs();
+ }
+
+ function onInputChange() {
+ // Read all current values into currentInputs (parse JSON for object-typed inputs)
+ inputs.forEach(f => {
+ const ta = inputElems.get(f.id);
+ if (!ta) return;
+ const raw = ta.value;
+ if (f.type === 'integer') currentInputs[f.id] = Number(raw);
+ else if (f.type === 'boolean') currentInputs[f.id] = raw === 'true';
+ else if (f.type === 'object' || f.type === 'array') {
+ try { currentInputs[f.id] = JSON.parse(raw); }
+ catch { currentInputs[f.id] = raw; }
+ } else {
+ currentInputs[f.id] = raw;
+ }
+ });
+ renderOutputs();
+ }
+
+ function renderOutputs() {
+ // Find a test whose inputs deep-equal currentInputs
+ const matchIdx = tests.findIndex(t => deepEqual(t.inputs, currentInputs));
+ if (matchIdx >= 0) {
+ const t = tests[matchIdx];
+ const lines: string[] = [];
+ outputs.forEach(f => {
+ const v = t.expected_outputs[f.id];
+ lines.push(`${f.id}: ${fmtValue(v)}`);
+ });
+ outputBox.textContent = lines.join('\n');
+ matchBadge.innerHTML = '';
+ matchBadge.appendChild(
+ el('span', {style: 'color:#1D9E75;'}, `✓ exact match: case "${t.name}"`));
+ } else {
+ outputBox.textContent = '(no recorded sample for these inputs — pick a recorded case above)';
+ matchBadge.innerHTML = '';
+ matchBadge.appendChild(
+ el('span', {style: 'color:#A48F2A;'}, '◯ external card — outputs are recorded samples only, not predicted'));
+ }
+ }
+
+ // Initial render
+ selectTest(0);
+ return root;
+}
+
+// ── Inline regex visualiser (chapter 13.16) ──────────────────────
+function renderInlineRegex(card: AlgorithmCard): HTMLElement {
+ const inline = card.implementation?.inline || {};
+ const pattern = inline.source || '';
+ const tests = card.tests || [];
+
+ const root = el('div', {style: 'padding:16px 20px;'});
+ root.appendChild(el('div', {style: 'font-size:11px;color:#888780;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;'}, 'inline · regex'));
+ root.appendChild(el('pre', {style: 'background:#1F2328;color:#E8E5DA;padding:12px;border-radius:3px;font-family:\"Source Code Pro\",monospace;font-size:13px;overflow-x:auto;'}, pattern));
+
+ // For each test, show the input text with matches highlighted
+ if (tests.length > 0) {
+ let re: RegExp | null = null;
+ try { re = new RegExp(pattern, 'g'); } catch (err) {
+ root.appendChild(el('div', {style: 'color:#E24B4A;font-size:12px;margin-top:8px;'}, `Invalid regex: ${(err as Error).message}`));
+ return root;
+ }
+ const firstInputKey = Object.keys(tests[0].inputs)[0];
+ tests.forEach(t => {
+ const text = String(t.inputs[firstInputKey] || '');
+ root.appendChild(el('div', {style: 'margin-top:14px;'},
+ el('div', {style: 'font-size:12px;color:#5F5E5A;margin-bottom:4px;'}, t.name),
+ renderHighlighted(text, re!),
+ ));
+ });
+ }
+ return root;
+}
+
+function renderHighlighted(text: string, re: RegExp): HTMLElement {
+ const out = el('div', {style: 'background:#F8F7F4;padding:10px;border-radius:3px;font-family:\"Source Code Pro\",monospace;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;'});
+ let lastIdx = 0;
+ re.lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(text)) !== null) {
+ if (m.index > lastIdx) out.appendChild(document.createTextNode(text.slice(lastIdx, m.index)));
+ out.appendChild(el('span', {style: 'background:#FFE8B3;color:#7A6112;padding:0 2px;border-radius:2px;'}, m[0]));
+ lastIdx = m.index + m[0].length;
+ if (m[0].length === 0) re.lastIndex++;
+ }
+ if (lastIdx < text.length) out.appendChild(document.createTextNode(text.slice(lastIdx)));
+ return out;
+}
+
+// ── Inline FEEL visualiser (placeholder — full evaluator is v1.5) ─
+function renderInlineFeel(card: AlgorithmCard): HTMLElement {
+ const inline = card.implementation?.inline || {};
+ const expr = inline.source || '';
+ const root = el('div', {style: 'padding:16px 20px;'});
+ root.appendChild(el('div', {style: 'font-size:11px;color:#888780;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;'}, 'inline · FEEL'));
+ root.appendChild(el('pre', {style: 'background:#1F2328;color:#E8E5DA;padding:12px;border-radius:3px;font-family:\"Source Code Pro\",monospace;font-size:13px;overflow-x:auto;'}, expr));
+ root.appendChild(el('div', {style: 'margin-top:12px;padding:10px;background:#FEF8E7;border:1px solid #EBD995;border-radius:3px;color:#7A6112;font-size:12px;'},
+ 'Live FEEL evaluation is v1.5 (planned). Use the sample browser below to see recorded input/output pairs.'));
+ return root;
+}
+
+// ── Inline DMN link-out ──────────────────────────────────────────
+function renderInlineDmnLinkOut(card: AlgorithmCard): HTMLElement {
+ const root = el('div', {style: 'padding:16px 20px;'});
+ root.appendChild(el('div', {style: 'font-size:11px;color:#888780;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;'}, 'inline · DMN'));
+ root.appendChild(el('div', {style: 'padding:12px;background:#F8F7F4;border-left:3px solid #C5C3BB;font-size:13px;color:#3D3929;line-height:1.5;'},
+ 'This card delegates to a DMN decision. ',
+ el('a', {href: '#', style: 'color:#0969DA;'}, 'Open the DMN cornerstone file'),
+ ' for the decision table visualiser.',
+ ));
+ return root;
+}
+
+// ── Composite placeholder (call-tree is v1.5) ────────────────────
+function renderComposite(card: AlgorithmCard): HTMLElement {
+ const composed = card.implementation?.composite?.composed_of || [];
+ const root = el('div', {style: 'padding:16px 20px;'});
+ root.appendChild(el('div', {style: 'font-size:11px;color:#888780;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;'}, `composite · ${composed.length} step(s)`));
+ composed.forEach((step, i) => {
+ root.appendChild(el('div', {style: 'padding:8px 12px;border:1px solid #E5E2DC;border-radius:3px;margin-bottom:6px;'},
+ el('div', {style: 'font-family:\"Source Code Pro\",monospace;font-size:12px;color:#3D3929;'}, `${i + 1}. ${step.id}`),
+ step.description ? el('div', {style: 'font-size:11px;color:#5F5E5A;margin-top:2px;'}, step.description) : null,
+ ));
+ });
+ return root;
+}
+
+// ── Polymorphic dispatch ─────────────────────────────────────────
+function renderBody(card: AlgorithmCard): HTMLElement {
+ const impl = card.implementation || {};
+ if (impl.type === 'inline') {
+ const lang = impl.inline?.language || impl.language;
+ if (lang === 'regex') return renderInlineRegex(card);
+ if (lang === 'feel') return renderInlineFeel(card);
+ if (lang === 'dmn') return renderInlineDmnLinkOut(card);
+ // rego, sql, wasm, other → fall back to source + sample browser
+ return renderInlineFallback(card, lang || 'unknown');
+ }
+ if (impl.type === 'composite') return renderComposite(card);
+ // 'external' or unknown → sample browser
+ return renderExternalSampleBrowser(card);
+}
+
+function renderInlineFallback(card: AlgorithmCard, language: string): HTMLElement {
+ const source = card.implementation?.inline?.source || '';
+ const root = el('div', {style: 'padding:16px 20px;'});
+ root.appendChild(el('div', {style: 'font-size:11px;color:#888780;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;'}, `inline · ${language}`));
+ root.appendChild(el('pre', {style: 'background:#1F2328;color:#E8E5DA;padding:12px;border-radius:3px;font-family:\"Source Code Pro\",monospace;font-size:13px;overflow-x:auto;max-height:280px;'}, source));
+ return root;
+}
+
+// ── Adapter entry point ──────────────────────────────────────────
+export function createCardAdapter(canvas: HTMLElement, _properties: HTMLElement | null): DiagramAdapter {
+ let currentCard: AlgorithmCard | null = null;
+
+ return {
+ async renderPreview(content: any): Promise {
+ clear(canvas);
+ const text = typeof content === 'string' ? content : String(content);
+
+ let card: AlgorithmCard;
+ try {
+ card = yaml.load(text) as AlgorithmCard;
+ } catch (err) {
+ canvas.appendChild(el('div', {style: 'padding:20px;color:#E24B4A;'}, `Failed to parse card YAML: ${(err as Error).message}`));
+ return;
+ }
+ if (!card || typeof card !== 'object' || !card.id) {
+ canvas.appendChild(el('div', {style: 'padding:20px;color:#E24B4A;'}, 'Not a valid UAPF algorithm card (missing id).'));
+ return;
+ }
+ currentCard = card;
+
+ // Compose: header + IO contract + polymorphic body
+ const wrap = el('div', {class: 'uapf-card-viewer', style: 'max-width:980px;margin:0 auto;background:#FFFFFF;'});
+ wrap.appendChild(renderHeader(card));
+ wrap.appendChild(renderIOContract(card));
+ wrap.appendChild(renderBody(card));
+
+ // Footer: tests count + spec reference
+ const footer = el('div', {style: 'padding:12px 20px;background:#F8F7F4;border-top:1px solid #E5E2DC;font-size:11px;color:#888780;display:flex;justify-content:space-between;'},
+ el('span', {}, `${(card.tests || []).length} embedded test case${(card.tests || []).length === 1 ? '' : 's'}`),
+ el('span', {}, 'UAPF v2.5.0 · chapter 13.16 viewer contract'),
+ );
+ wrap.appendChild(footer);
+
+ canvas.appendChild(wrap);
+ },
+
+ // "Edit File" → redirect to Gitea's built-in editor. We get the file
+ // path from window.location (the repo file view) and navigate to the
+ // _edit/ URL. Done lazily here instead of from index.ts so we keep
+ // the dispatch generic.
+ async enterEdit(_content: any): Promise {
+ const loc = window.location.pathname;
+ // /{owner}/{repo}/src/branch/{branch}/{path} → /{owner}/{repo}/_edit/{branch}/{path}
+ const editUrl = loc.replace('/src/branch/', '/_edit/').replace('/src/commit/', '/_edit/');
+ if (editUrl === loc) {
+ // fallback: do nothing
+ return;
+ }
+ window.location.href = editUrl;
+ },
+
+ destroy(): void {
+ clear(canvas);
+ currentCard = null;
+ },
+ };
+}
diff --git a/web_src/js/features/diagrams/index.ts b/web_src/js/features/diagrams/index.ts
index 029e44b..23176e2 100644
--- a/web_src/js/features/diagrams/index.ts
+++ b/web_src/js/features/diagrams/index.ts
@@ -114,6 +114,10 @@ async function createAdapter(type: string, canvas: HTMLElement, properties: HTML
const {createRulesetAdapter} = await import('./ruleset.ts');
return createRulesetAdapter(canvas);
}
+ case 'card': {
+ const {createCardAdapter} = await import('./card.ts');
+ return createCardAdapter(canvas, properties);
+ }
default:
return null;
}
diff --git a/web_src/js/features/diagrams/uapf-algorithm-overlay.ts b/web_src/js/features/diagrams/uapf-algorithm-overlay.ts
new file mode 100644
index 0000000..377bc0f
--- /dev/null
+++ b/web_src/js/features/diagrams/uapf-algorithm-overlay.ts
@@ -0,0 +1,170 @@
+// UAPF Algorithm Card visual decoration for bpmn-js, via the public Overlays API.
+//
+// For every bpmn:serviceTask / bpmn:task carrying the v2.4.0
+// uapf24:algorithmCardRef attribute, attaches an HTML overlay with:
+// - a custom "algorithm card" icon (three stacked rectangles + ƒ)
+// - the card id (truncated if long)
+// - a colored risk-class dot (green / amber / red)
+// Card metadata is loaded asynchronously from algorithms/{id}.card.yaml
+// in the same repo. The risk dot starts neutral and updates once the
+// card sidecar resolves.
+//
+// This avoids adding bpmn-js BaseRenderer subclasses — it sits entirely
+// on the public Overlays API.
+
+import {getRiskClass, loadCardSidecar, type CardMeta} from './uapf-card-loader.ts';
+
+const UAPF_NS_V24 = 'https://uapf.dev/bpmn/v2.4';
+
+function readAlgorithmCardRef(businessObject: any): string | null {
+ if (!businessObject) return null;
+ const attrs = businessObject.$attrs || {};
+ const candidates = [
+ attrs['uapf24:algorithmCardRef'],
+ attrs['uapf:algorithmCardRef'],
+ attrs[`{${UAPF_NS_V24}}algorithmCardRef`],
+ attrs['algorithmCardRef'],
+ ];
+ for (const v of candidates) {
+ if (typeof v === 'string' && v.length > 0) return v;
+ }
+ return null;
+}
+
+const RISK_DOT_COLOR: Record = {
+ green: '#1D9E75',
+ amber: '#EF9F27',
+ red: '#E24B4A',
+ unknown: '#888780',
+};
+
+function buildOverlayHtml(cardRef: string, meta: CardMeta | null): HTMLElement {
+ const root = document.createElement('div');
+ root.className = 'uapf-algo-overlay';
+ root.style.cssText = [
+ 'position: absolute',
+ 'top: 0',
+ 'left: 0',
+ 'width: 100%',
+ 'height: 100%',
+ 'pointer-events: none',
+ 'font-family: Arial, sans-serif',
+ 'font-size: 10px',
+ 'color: #5F5E5A',
+ ].join('; ');
+
+ // Algorithm icon — three stacked cards with ƒ — placed top-left over the
+ // default serviceTask gear position.
+ const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ icon.setAttribute('width', '34');
+ icon.setAttribute('height', '24');
+ icon.setAttribute('viewBox', '0 0 34 24');
+ icon.style.cssText = 'position:absolute;top:4px;left:4px;background:#FFFFFF;border-radius:2px;';
+ icon.innerHTML = [
+ ' ',
+ ' ',
+ ' ',
+ 'ƒ ',
+ ].join('');
+ root.appendChild(icon);
+
+ // Risk dot top-right
+ const dot = document.createElement('div');
+ const riskClass = meta ? getRiskClass(meta) : 'unknown';
+ dot.className = `uapf-risk-dot uapf-risk-${riskClass}`;
+ dot.style.cssText = [
+ 'position:absolute',
+ 'top:6px',
+ 'right:6px',
+ 'width:10px',
+ 'height:10px',
+ 'border-radius:50%',
+ `background:${RISK_DOT_COLOR[riskClass] || RISK_DOT_COLOR.unknown}`,
+ 'border:1px solid #FFFFFF',
+ ].join('; ');
+ dot.title = `Algorithm card: ${cardRef}` + (meta ? ` (risk: ${riskClass})` : '');
+ root.appendChild(dot);
+
+ // Two-line label at the bottom of the task: card id, then metadata strip
+ const labelBox = document.createElement('div');
+ labelBox.style.cssText = [
+ 'position:absolute',
+ 'bottom:2px',
+ 'left:0',
+ 'right:0',
+ 'text-align:center',
+ 'padding:0 4px',
+ 'line-height:1.2',
+ 'background:linear-gradient(to top, #FFFFFF 60%, rgba(255,255,255,0))',
+ ].join('; ');
+
+ const idLine = document.createElement('div');
+ idLine.style.cssText = 'font-size:9px;color:#5F5E5A;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
+ idLine.textContent = cardRef;
+ idLine.title = cardRef;
+ labelBox.appendChild(idLine);
+
+ if (meta) {
+ const metaLine = document.createElement('div');
+ metaLine.style.cssText = 'font-size:9px;color:#888780;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
+ const parts = [
+ meta.version ? 'v' + meta.version : null,
+ meta.algorithm_kind || null,
+ meta.determinism || 'deterministic',
+ ].filter(Boolean);
+ metaLine.textContent = parts.join(' · ');
+ labelBox.appendChild(metaLine);
+ }
+
+ root.appendChild(labelBox);
+ return root;
+}
+
+/**
+ * Walks all shapes in the diagram. For each serviceTask/task with an
+ * algorithmCardRef, adds an Overlay. The overlay is created immediately
+ * with neutral metadata and updated when the card sidecar loads.
+ */
+export function attachUapfAlgorithmOverlays(viewerOrModeler: any): void {
+ if (!viewerOrModeler) return;
+ let overlays: any;
+ let elementRegistry: any;
+ try {
+ overlays = viewerOrModeler.get('overlays');
+ elementRegistry = viewerOrModeler.get('elementRegistry');
+ } catch (err) {
+ console.debug('[uapf-overlay] bpmn-js modules unavailable', err);
+ return;
+ }
+ if (!overlays || !elementRegistry) return;
+
+ const elements: any[] = elementRegistry.getAll();
+ for (const element of elements) {
+ const bo = element.businessObject;
+ if (!bo) continue;
+ const type = bo.$type;
+ if (type !== 'bpmn:ServiceTask' && type !== 'bpmn:Task') continue;
+ const cardRef = readAlgorithmCardRef(bo);
+ if (!cardRef) continue;
+
+ // Add an initial overlay with neutral risk
+ const html = buildOverlayHtml(cardRef, null);
+ const overlayId = overlays.add(element.id, 'uapf-algo-card', {
+ position: {top: 0, left: 0},
+ html,
+ });
+
+ // Try to fetch the card sidecar; on resolve, swap the overlay HTML
+ loadCardSidecar(cardRef).then((meta) => {
+ if (!meta) return;
+ overlays.remove(overlayId);
+ const newHtml = buildOverlayHtml(cardRef, meta);
+ overlays.add(element.id, 'uapf-algo-card', {
+ position: {top: 0, left: 0},
+ html: newHtml,
+ });
+ }).catch((err) => {
+ console.debug('[uapf-overlay] card sidecar fetch failed', cardRef, err);
+ });
+ }
+}
diff --git a/web_src/js/features/diagrams/uapf-card-loader.ts b/web_src/js/features/diagrams/uapf-card-loader.ts
new file mode 100644
index 0000000..b941deb
--- /dev/null
+++ b/web_src/js/features/diagrams/uapf-card-loader.ts
@@ -0,0 +1,140 @@
+// UAPF Algorithm Card sidecar loader for bpmn-js custom renderer.
+//
+// Given an algorithm card id like "algo.semantic_document_analysis.pii_redactor",
+// fetches the corresponding YAML file from the same repo's algorithms/ folder
+// via the Gitea raw-file endpoint. The card id's terminal segment (after the
+// last dot) is treated as the filename: algorithms/{terminal}.card.yaml.
+//
+// Cached per-page (per card id).
+
+export interface CardMeta {
+ id?: string;
+ name?: string;
+ version?: string;
+ algorithm_kind?: string;
+ determinism?: string;
+ risk?: {
+ aiActRiskClass?: string;
+ humanOversight?: string;
+ };
+}
+
+const cardCache = new Map();
+
+function repoBasePath(): string | null {
+ // Gitea URLs look like /:owner/:repo/...
+ // We want /:owner/:repo as the prefix for raw URLs.
+ const m = window.location.pathname.match(/^\/([^/]+)\/([^/]+)/);
+ if (!m) return null;
+ return `/${m[1]}/${m[2]}`;
+}
+
+function currentBranch(): string {
+ // /:owner/:repo/src/branch/:branch/... or /:owner/:repo/raw/branch/:branch/...
+ const m = window.location.pathname.match(/\/(?:src|raw)\/(?:branch|commit)\/([^/]+)/);
+ return m ? decodeURIComponent(m[1]) : 'main';
+}
+
+function cardIdToFilename(cardId: string): string {
+ // "algo.semantic_document_analysis.pii_redactor" -> "pii_redactor.card.yaml"
+ const segments = cardId.split('.');
+ const terminal = segments[segments.length - 1];
+ return `${terminal}.card.yaml`;
+}
+
+// Minimal YAML field-extractor: handles top-level "key: value" pairs.
+// We don't need a full YAML parser — we only need a handful of scalars
+// (version, name, algorithm_kind, determinism, risk.aiActRiskClass,
+// risk.humanOversight). Cards are well-formed YAML authored by us.
+function extractField(yaml: string, key: string): string | null {
+ // Top-level: line begins with key followed by colon
+ const re = new RegExp(`^${key}:\\s*(.+?)\\s*$`, 'm');
+ const m = yaml.match(re);
+ if (!m) return null;
+ let v = m[1].trim();
+ // strip surrounding quotes
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
+ v = v.slice(1, -1);
+ }
+ return v;
+}
+
+function extractNested(yaml: string, parent: string, child: string): string | null {
+ // Look for `parent:` line followed by indented `child:` line
+ const re = new RegExp(`^${parent}:\\s*\\n(?:^[ \\t]+.*\\n)*?^[ \\t]+${child}:\\s*(.+?)\\s*$`, 'm');
+ const m = yaml.match(re);
+ if (!m) return null;
+ let v = m[1].trim();
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
+ v = v.slice(1, -1);
+ }
+ return v;
+}
+
+function parseCardMeta(yaml: string): CardMeta {
+ const meta: CardMeta = {};
+ const id = extractField(yaml, 'id'); if (id) meta.id = id;
+ const name = extractField(yaml, 'name'); if (name) meta.name = name;
+ const version = extractField(yaml, 'version'); if (version) meta.version = version;
+ const kind = extractField(yaml, 'algorithm_kind'); if (kind) meta.algorithm_kind = kind;
+ const det = extractField(yaml, 'determinism'); if (det) meta.determinism = det;
+
+ const ai = extractNested(yaml, 'risk', 'aiActRiskClass');
+ const ov = extractNested(yaml, 'risk', 'humanOversight');
+ if (ai || ov) {
+ meta.risk = {};
+ if (ai) meta.risk.aiActRiskClass = ai;
+ if (ov) meta.risk.humanOversight = ov;
+ }
+ return meta;
+}
+
+export async function loadCardSidecar(cardId: string): Promise {
+ if (cardCache.has(cardId)) return cardCache.get(cardId) ?? null;
+
+ const repoBase = repoBasePath();
+ if (!repoBase) {
+ cardCache.set(cardId, null);
+ return null;
+ }
+ const filename = cardIdToFilename(cardId);
+ const branch = currentBranch();
+ const url = `${repoBase}/raw/branch/${encodeURIComponent(branch)}/algorithms/${encodeURIComponent(filename)}`;
+
+ try {
+ const response = await fetch(url, {
+ headers: {Accept: 'text/plain'},
+ credentials: 'same-origin',
+ });
+ if (!response.ok) {
+ cardCache.set(cardId, null);
+ return null;
+ }
+ const text = await response.text();
+ const meta = parseCardMeta(text);
+ cardCache.set(cardId, meta);
+ return meta;
+ } catch {
+ cardCache.set(cardId, null);
+ return null;
+ }
+}
+
+/**
+ * Returns one of: 'green' | 'amber' | 'red' | 'unknown'
+ * Mapping (per UAPF v2.4.0 chapter 13.10):
+ * - red: risk.aiActRiskClass = high OR risk.humanOversight = mandatory
+ * - amber: determinism in (stochastic, learned) OR risk.aiActRiskClass = limited with advisory oversight
+ * - green: deterministic and risk class minimal/limited with non-mandatory oversight
+ */
+export function getRiskClass(meta: CardMeta): 'green' | 'amber' | 'red' | 'unknown' {
+ if (!meta) return 'unknown';
+ const aiRisk = meta.risk?.aiActRiskClass;
+ const ov = meta.risk?.humanOversight;
+ const det = meta.determinism || 'deterministic';
+
+ if (aiRisk === 'high' || ov === 'mandatory') return 'red';
+ if (det === 'stochastic' || det === 'learned') return 'amber';
+ if (aiRisk === 'limited' && ov && ov !== 'none') return 'amber';
+ return 'green';
+}
From 6c4ab156c5401058e308167dd2c49714c8724fd6 Mon Sep 17 00:00:00 2001
From: Rihards Gailums
Date: Thu, 21 May 2026 09:25:52 +0000
Subject: [PATCH 3/6] fix(docker): chmod + COPY use /src/processgit (Makefile
output name)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Dockerfile expected /src/gitea for both chmod and COPY in the
build-env stage, but the Makefile produces /src/processgit (the
EXECUTABLE variable is set to 'processgit' on Linux, matching the
fork name). The runtime stage's COPY still places the binary at
/app/gitea/gitea for s6 service compatibility.
Also updates the base image from node:22.6.0-alpine3.22 (removed
from Docker Hub) to node:22-alpine.
This unblocks the proper Preview-tab integration for *.card.yaml
files — the new binary (with DiagramCard detection from
modules/diagrams/detect.go) now builds cleanly via docker build.
---
Dockerfile | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
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
From b02e248cd2e2d9d67831f46e7b63d23f416a38ad Mon Sep 17 00:00:00 2001
From: Rihards Gailums
Date: Thu, 21 May 2026 09:30:06 +0000
Subject: [PATCH 4/6] feat(viewer): side-panel drawer for BPMN algorithm card
click-through
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
UAPF v2.5.0 chapter 13.16: when the user clicks an algorithm task
overlay in the BPMN diagram, the Algorithm Card viewer opens in a
side-panel drawer that slides in from the right, preserving the BPMN
process context behind it.
Two changes:
- web_src/js/features/diagrams/uapf-algorithm-overlay.ts: the overlay
root div is now interactive (cursor: pointer, pointer-events: auto)
and carries data-uapf-card-ref. The attachUapfAlgorithmOverlays
walker binds a click handler via bindOverlayClick() that locates
the bpmn-js-managed overlay wrapper by data-overlay-id and dispatches
to openCardDrawer().
- web_src/js/features/diagrams/uapf-card-drawer.ts (new, 191 lines):
side-panel drawer module. Slides in from the right (transform
transition), backdrop fades in (opacity transition), Escape and
backdrop click both close. Body lazily imports ./card.ts (the same
polymorphic Preview-tab adapter) so the drawer code doesn't bloat
the BPMN bundle when unused. Card YAML fetched via
/\${owner}/\${repo}/raw/branch/\${branch}/algorithms/\${name}.card.yaml
parsed from window.location.pathname.
Frontend bundle rebuilt: web_src_js_features_diagrams_bpmn_ts chunk
hash ffc5628b → 992a62c1 (7.8 KB → 11.7 KB; drawer code lazy-imported
via card.ts chunk f5f9df6d, unchanged). Deployed to
/data/gitea/public/assets/js/ as CUSTOM_PATH overrides; bindata in
the binary still has the older chunks until next image rebuild.
---
.../diagrams/uapf-algorithm-overlay.ts | 35 +++-
.../js/features/diagrams/uapf-card-drawer.ts | 191 ++++++++++++++++++
2 files changed, 224 insertions(+), 2 deletions(-)
create mode 100644 web_src/js/features/diagrams/uapf-card-drawer.ts
diff --git a/web_src/js/features/diagrams/uapf-algorithm-overlay.ts b/web_src/js/features/diagrams/uapf-algorithm-overlay.ts
index 377bc0f..cf016bd 100644
--- a/web_src/js/features/diagrams/uapf-algorithm-overlay.ts
+++ b/web_src/js/features/diagrams/uapf-algorithm-overlay.ts
@@ -13,6 +13,7 @@
// on the public Overlays API.
import {getRiskClass, loadCardSidecar, type CardMeta} from './uapf-card-loader.ts';
+import {openCardDrawer} from './uapf-card-drawer.ts';
const UAPF_NS_V24 = 'https://uapf.dev/bpmn/v2.4';
@@ -47,11 +48,15 @@ function buildOverlayHtml(cardRef: string, meta: CardMeta | null): HTMLElement {
'left: 0',
'width: 100%',
'height: 100%',
- 'pointer-events: none',
+ 'pointer-events: auto',
+ 'cursor: pointer',
'font-family: Arial, sans-serif',
'font-size: 10px',
'color: #5F5E5A',
].join('; ');
+ // UAPF v2.5.0 chapter 13.16: this overlay is clickable — opens the
+ // Algorithm Card viewer in a side-panel drawer over the BPMN diagram.
+ root.setAttribute('data-uapf-card-ref', cardRef);
// Algorithm icon — three stacked cards with ƒ — placed top-left over the
// default serviceTask gear position.
@@ -159,12 +164,38 @@ export function attachUapfAlgorithmOverlays(viewerOrModeler: any): void {
if (!meta) return;
overlays.remove(overlayId);
const newHtml = buildOverlayHtml(cardRef, meta);
- overlays.add(element.id, 'uapf-algo-card', {
+ const newOverlayId = overlays.add(element.id, 'uapf-algo-card', {
position: {top: 0, left: 0},
html: newHtml,
});
+ // Hook click for the updated overlay
+ bindOverlayClick(newOverlayId, cardRef);
}).catch((err) => {
console.debug('[uapf-overlay] card sidecar fetch failed', cardRef, err);
});
+
+ // Hook click for the initial overlay
+ bindOverlayClick(overlayId, cardRef);
}
}
+
+/**
+ * Find the overlay DOM element by id and bind a click handler that opens
+ * the Algorithm Card viewer in a side-panel drawer (UAPF chapter 13.16).
+ * bpmn-js inserts overlays into a container with class .djs-overlay; the
+ * id attribute is set to the overlay id we got from overlays.add.
+ */
+function bindOverlayClick(overlayId: string, cardRef: string): void {
+ // bpmn-js sets the overlay's data-overlay-id attribute on the wrapper.
+ // Defer to next tick so the DOM has been updated.
+ setTimeout(() => {
+ const wrapper = document.querySelector(`[data-overlay-id="${overlayId}"]`);
+ if (!wrapper) return;
+ wrapper.style.pointerEvents = 'auto';
+ wrapper.addEventListener('click', (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ openCardDrawer(cardRef);
+ });
+ }, 0);
+}
diff --git a/web_src/js/features/diagrams/uapf-card-drawer.ts b/web_src/js/features/diagrams/uapf-card-drawer.ts
new file mode 100644
index 0000000..4e171fe
--- /dev/null
+++ b/web_src/js/features/diagrams/uapf-card-drawer.ts
@@ -0,0 +1,191 @@
+// UAPF v2.5.0 chapter 13.16: side-panel drawer that opens the Algorithm
+// Card viewer over the BPMN diagram when the user clicks the algorithm
+// task overlay.
+//
+// The drawer:
+// - slides in from the right
+// - covers roughly 50% of the viewport width on desktop, full width on mobile
+// - shows the same polymorphic card viewer used in the Preview tab (lazy-
+// imports ./card.ts so we don't pull js-yaml into the BPMN bundle unless
+// the drawer is actually opened)
+// - has a close button and dismisses on backdrop click / Escape key
+// - preserves the BPMN process context behind it (no navigation away)
+
+let drawerEl: HTMLDivElement | null = null;
+let backdropEl: HTMLDivElement | null = null;
+let escHandler: ((e: KeyboardEvent) => void) | null = null;
+let currentCardRef: string | null = null;
+
+function ensureDom(): void {
+ if (drawerEl && backdropEl) return;
+
+ backdropEl = document.createElement('div');
+ backdropEl.className = 'uapf-card-drawer-backdrop';
+ backdropEl.style.cssText = [
+ 'position: fixed',
+ 'inset: 0',
+ 'background: rgba(0, 0, 0, 0.32)',
+ 'opacity: 0',
+ 'pointer-events: none',
+ 'transition: opacity 200ms ease',
+ 'z-index: 998',
+ ].join('; ');
+ backdropEl.addEventListener('click', closeCardDrawer);
+
+ drawerEl = document.createElement('div');
+ drawerEl.className = 'uapf-card-drawer';
+ drawerEl.style.cssText = [
+ 'position: fixed',
+ 'top: 0',
+ 'right: 0',
+ 'width: min(720px, 100vw)',
+ 'height: 100vh',
+ 'background: #FFFFFF',
+ 'box-shadow: -8px 0 24px rgba(0, 0, 0, 0.16)',
+ 'transform: translateX(100%)',
+ 'transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1)',
+ 'z-index: 999',
+ 'display: flex',
+ 'flex-direction: column',
+ 'overflow: hidden',
+ ].join('; ');
+
+ // Header bar with title + close button
+ const header = document.createElement('div');
+ header.style.cssText = [
+ 'display: flex',
+ 'align-items: center',
+ 'justify-content: space-between',
+ 'padding: 12px 20px',
+ 'background: #F8F7F4',
+ 'border-bottom: 1px solid #E5E2DC',
+ 'flex-shrink: 0',
+ ].join('; ');
+ const title = document.createElement('div');
+ title.id = 'uapf-card-drawer-title';
+ title.style.cssText = 'font-size: 13px; color: #5F5E5A; font-family: "Source Code Pro", monospace;';
+ title.textContent = 'Algorithm Card';
+ const closeBtn = document.createElement('button');
+ closeBtn.type = 'button';
+ closeBtn.setAttribute('aria-label', 'Close');
+ closeBtn.style.cssText = [
+ 'border: 1px solid #E5E2DC',
+ 'background: #FFFFFF',
+ 'color: #5F5E5A',
+ 'border-radius: 4px',
+ 'padding: 4px 10px',
+ 'font-size: 13px',
+ 'cursor: pointer',
+ ].join('; ');
+ closeBtn.textContent = '✕ Close';
+ closeBtn.addEventListener('click', closeCardDrawer);
+ header.appendChild(title);
+ header.appendChild(closeBtn);
+
+ // Body (scrollable)
+ const body = document.createElement('div');
+ body.id = 'uapf-card-drawer-body';
+ body.style.cssText = [
+ 'flex: 1',
+ 'overflow-y: auto',
+ 'background: #FFFFFF',
+ ].join('; ');
+
+ drawerEl.appendChild(header);
+ drawerEl.appendChild(body);
+
+ document.body.appendChild(backdropEl);
+ document.body.appendChild(drawerEl);
+}
+
+function getRepoFromUrl(): {owner: string; repo: string; branch: string} | null {
+ // URL shape: /{owner}/{repo}/src/branch/{branch}/{path...}
+ const m = window.location.pathname.match(/^\/([^\/]+)\/([^\/]+)\/src\/(?:branch|commit|tag)\/([^\/]+)\//);
+ if (!m) return null;
+ return {owner: m[1], repo: m[2], branch: m[3]};
+}
+
+function buildRawCardUrl(cardRef: string): string | null {
+ const ctx = getRepoFromUrl();
+ if (!ctx) return null;
+ // Card IDs follow algo... The file in the repo lives at
+ // algorithms/.card.yaml — strip everything before the final dot.
+ const parts = cardRef.split('.');
+ const fileBase = parts[parts.length - 1];
+ return `/${encodeURIComponent(ctx.owner)}/${encodeURIComponent(ctx.repo)}/raw/branch/${encodeURIComponent(ctx.branch)}/algorithms/${encodeURIComponent(fileBase)}.card.yaml`;
+}
+
+export async function openCardDrawer(cardRef: string): Promise {
+ ensureDom();
+ if (!drawerEl || !backdropEl) return;
+
+ currentCardRef = cardRef;
+
+ // Update title
+ const title = drawerEl.querySelector('#uapf-card-drawer-title') as HTMLDivElement | null;
+ if (title) title.textContent = cardRef;
+
+ // Reset body and show loading
+ const body = drawerEl.querySelector('#uapf-card-drawer-body') as HTMLDivElement | null;
+ if (body) {
+ body.innerHTML = 'Loading card…
';
+ }
+
+ // Open drawer
+ requestAnimationFrame(() => {
+ if (!drawerEl || !backdropEl) return;
+ backdropEl.style.opacity = '1';
+ backdropEl.style.pointerEvents = 'auto';
+ drawerEl.style.transform = 'translateX(0)';
+ });
+
+ // Escape key handler
+ if (!escHandler) {
+ escHandler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') closeCardDrawer();
+ };
+ document.addEventListener('keydown', escHandler);
+ }
+
+ // Fetch raw card YAML
+ const rawUrl = buildRawCardUrl(cardRef);
+ if (!rawUrl) {
+ if (body) body.innerHTML = 'Could not resolve card URL from page context.
';
+ return;
+ }
+
+ let yamlText: string;
+ try {
+ const r = await fetch(rawUrl, {headers: {Accept: 'text/plain', 'X-Requested-With': 'XMLHttpRequest'}});
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ yamlText = await r.text();
+ } catch (err) {
+ if (body) body.innerHTML = `Failed to load card: ${(err as Error).message}
`;
+ return;
+ }
+
+ // Guard against race: user may have closed/reopened with a different card
+ if (currentCardRef !== cardRef || !body) return;
+
+ // Lazy-load the card adapter (same module used by the Preview tab) and render
+ try {
+ const {createCardAdapter} = await import('./card.ts');
+ body.innerHTML = '';
+ const adapter = createCardAdapter(body, null);
+ await adapter.renderPreview(yamlText);
+ } catch (err) {
+ body.innerHTML = `Failed to mount viewer: ${(err as Error).message}
`;
+ }
+}
+
+export function closeCardDrawer(): void {
+ if (!drawerEl || !backdropEl) return;
+ drawerEl.style.transform = 'translateX(100%)';
+ backdropEl.style.opacity = '0';
+ backdropEl.style.pointerEvents = 'none';
+ currentCardRef = null;
+ if (escHandler) {
+ document.removeEventListener('keydown', escHandler);
+ escHandler = null;
+ }
+}
From 840ccf09f6fb268646db1c9c39369d96114047d8 Mon Sep 17 00:00:00 2001
From: Rihards Gailums
Date: Wed, 3 Jun 2026 17:02:50 +0000
Subject: [PATCH 5/6] feat(uapf-mcp): per-repo /uapf-mcp execution MCP scope
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a third per-repo surface alongside /mcp (knowledge) and /uapf-ip
(descriptor): /{owner}/{repo}/uapf-mcp exposes the repo as a RUNNABLE UAPF
package. An agent (e.g. Copilot Studio) connects here and gets execution tools
(start_session, execute_process, evaluate_decision, get_session, get_audit,
abort_session, list_algorithms, get_algorithm_card) that ProcessGit delegates
to the UAPF engine via the embedded github.com/UAPFormat/uapf-mcp-go module.
- routers/web/repo/uapfmcp.go: UAPFMCPEndpoint. Resolves the package live from
the repo's uapf.yaml on each connection (mirrors uapfip.go's manifest read),
builds a pkgsource.Package + hostManifest from requires_capabilities, and
forwards MCP traffic to the engine. ProcessGit terminates MCP and delegates;
it does not hold sessions or evaluate decisions (runtime stays the engine).
- routers/web/web.go: register /{username}/{reponame}/uapf-mcp (GET,POST,DELETE,
OPTIONS) under optSignInIgnoreCsrf + RepoAssignment, mirroring /mcp.
- go.mod/go.sum: add github.com/UAPFormat/uapf-mcp-go v0.1.1.
Config via env (wired to bundled engine + LLM gateway in the release):
UAPF_ENGINE_URL, UAPF_HOST_BASE_URL, UAPF_HOST_DID, UAPF_ARCHIVE_BASE.
Additive + backward compatible: only answers for repos with a valid uapf.yaml.
Builds clean (go build ./routers/web/... in golang:1.25). Not yet wired into a
release bundle (engine+llm-gateway services) — that is deliverable B step 4.
---
go.mod | 1 +
go.sum | 4 +-
routers/web/repo/uapfmcp.go | 149 ++++++++++++++++++++++++++++++++++++
routers/web/web.go | 5 ++
4 files changed, 157 insertions(+), 2 deletions(-)
create mode 100644 routers/web/repo/uapfmcp.go
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/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)
From 193e1111bb11b4eeb2757f3a828b4ce683943132 Mon Sep 17 00:00:00 2001
From: Rihards Gailums
Date: Wed, 3 Jun 2026 17:33:27 +0000
Subject: [PATCH 6/6] deploy(uapf-mcp): release-bundle overlay for the
/uapf-mcp execution scope
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
deploy/docker-compose.uapf.example.yml wires the new scope:
- processgit env: UAPF_ENGINE_URL (http://uapf-engine:4000), UAPF_ARCHIVE_BASE
(http://processgit:3000, internal — no public egress), UAPF_HOST_DID,
UAPF_HOST_BASE_URL (capability host / LLM gateway).
- uapf-engine service under profile 'bundled-engine' for fresh installs;
omitted on hosts that already provide an engine on the network (Kojusalas:
the opendms stack already serves 'uapf-engine' on deploy_default, verified
reachable from the processgit container at :4000/health).
- llm-gateway documented as a UAPF capability-host adapter
(POST /uapf/host/capability/{ns}/{op}) — required only for ai.* packages.
Validated: docker compose -f docker-compose.yml -f docker-compose.uapf.example.yml
config merges cleanly (env applied; engine parses under --profile bundled-engine).
Does not modify the live deploy/docker-compose.yml or .env.
---
deploy/docker-compose.uapf.example.yml | 66 ++++++++++++++++++++++++++
1 file changed, 66 insertions(+)
create mode 100644 deploy/docker-compose.uapf.example.yml
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