diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f543bcf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,36 @@ +# EditorConfig — https://editorconfig.org +# Top-level config; no parent lookups beyond this file. +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true +insert_final_newline = true + +# Go uses tabs (gofmt is non-negotiable). +[*.go] +indent_style = tab + +# TS / JS / JSON — 2-space (matches Prettier + tsconfig conventions). +[*.{ts,tsx,js,jsx,mjs,cjs,json,jsonc}] +indent_style = space +indent_size = 2 + +# YAML — 2-space (contract/*.yaml, GitHub Actions). +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +# Markdown — preserve trailing spaces (two-space line-break trick). +[*.md] +trim_trailing_whitespace = false + +# Makefiles require literal tabs. +[Makefile] +indent_style = tab + +[**/Makefile] +indent_style = tab diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..47afa6c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,31 @@ +# CODEOWNERS — Review routing for edge-agents +# +# Syntax: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +# Order matters: the LAST matching pattern wins for a given file. +# +# NOTE: The @ForestHubAI/maintainers team must exist in the GitHub org +# (Org Settings → Teams) for these mentions to resolve. Without the team, +# GitHub will show "Unknown owner" warnings on the Branch protection / +# PR review UI but CODEOWNERS routing will silently no-op. + +# --------------------------------------------------------------------------- +# Default owner — applies to anything not covered by a more specific rule below +# --------------------------------------------------------------------------- +* @ForestHubAI/maintainers + +# --------------------------------------------------------------------------- +# Language subtrees +# --------------------------------------------------------------------------- +/go/ @ForestHubAI/maintainers +/ts/ @ForestHubAI/maintainers + +# --------------------------------------------------------------------------- +# Contract subtree — CRITICAL: this is the source of truth. Drift between +# go/ and ts/ codegen from the OpenAPI 3.0.3 contracts must always be reviewed. +# --------------------------------------------------------------------------- +/contract/ @ForestHubAI/maintainers + +# --------------------------------------------------------------------------- +# Repo meta: CI workflows, issue templates, CODEOWNERS itself +# --------------------------------------------------------------------------- +/.github/ @ForestHubAI/maintainers diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ccc0503..348aa42 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -100,11 +100,14 @@ Use clear, prefixed messages: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`. ## License and Contributor Agreement -edge-agents is dual-licensed: the public release is distributed under -[AGPL-3.0](../LICENSE), and ForestHub offers separate commercial licenses for use -cases that are incompatible with the AGPL (commercial licensing: root@foresthub.ai). -To keep this model viable, every contribution must grant ForestHub the rights needed -to offer it under both license regimes. +edge-agents uses a **two-tier license model**: the `contract/` and +`ts/workflow-core/` subdirectories are released under [Apache-2.0](../contract/LICENSE), +and all other components (engine, LLM proxy, workflow-builder, app) are released +under [AGPL-3.0](../LICENSE) with the option for ForestHub to also offer them under a +separate commercial license for use cases that are incompatible with the AGPL +(commercial licensing: root@foresthub.ai). To keep this model viable, every +contribution must grant ForestHub the rights needed to offer it under both license +regimes. **By submitting a contribution (pull request, patch, or any other code or documentation change), you agree to the following terms:** @@ -153,3 +156,16 @@ including for presently unknown forms of use to the extent allowed by §31a UrhG You confirm these terms by checking the Contributor License Agreement boxes in the pull request template when you open your PR. A maintainer verifies this before merging; a PR whose CLA boxes are not checked will not be merged. + +### Which license your contribution falls under + +The repository uses a two-tier license model: + +- Contributions to `contract/` and `ts/workflow-core/` are released under + **Apache-2.0**. +- Contributions to all other paths (engine, LLM proxy, workflow-builder, app) are + released under **AGPL-3.0-only** with the option for ForestHub to also offer them + under a commercial license (per the CLA above). + +If your PR touches both tiers, the per-file license header (or, where absent, the +directory-level `LICENSE`/`NOTICE`) governs. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..cd7fe7d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,18 @@ +# GitHub Sponsors and other funding platforms for this repository. +# Reference: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository + +github: ForestHubAI + +# Additional platforms (uncomment to enable later): +# patreon: # Replace with a single Patreon username +# open_collective: # Replace with a single Open Collective username +# ko_fi: # Replace with a single Ko-fi username +# tidelift: # Replace with a single Tidelift platform-name/package-name +# community_bridge: # Replace with a single Community Bridge project-name +# liberapay: # Replace with a single Liberapay username +# issuehunt: # Replace with a single IssueHunt username +# lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name +# polar: # Replace with a single Polar username +# buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +# thanks_dev: # Replace with a single thanks.dev username +# custom: # Replace with up to 4 custom sponsorship URLs diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..34fbc71 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,31 @@ +# Issue-template chooser config. +# Reference: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository +# +# Forces contributors to pick a structured template (bug_report / feature_request) +# instead of opening blank issues. The contact_links route off-topic intents +# (questions, security reports, commercial inquiries) to the right channel so +# the issue tracker stays focused on actionable engineering work. + +blank_issues_enabled: false + +contact_links: + - name: Question / Discussion + url: https://github.com/ForestHubAI/edge-agents/discussions + about: | + Have a question, an idea, or want to discuss usage? Please open a + GitHub Discussion instead of an issue. (If Discussions is not yet + enabled on this repo, this link will 404 until the maintainer turns + it on in Settings → Features.) + + - name: Security Vulnerability + url: https://github.com/ForestHubAI/edge-agents/security/advisories/new + about: | + Please do NOT open a public issue for security vulnerabilities. Use + GitHub's private vulnerability reporting, or email root@foresthub.ai. + See .github/SECURITY.md for scope and process. + + - name: Commercial License Inquiry + url: mailto:root@foresthub.ai + about: | + edge-agents is dual-licensed (AGPL-3.0-only + commercial). For use + cases that are incompatible with AGPL, contact root@foresthub.ai. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5cfb35d..491c85e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -26,6 +26,6 @@ ## Contributor License Agreement -- [ ] I agree to the terms in [CONTRIBUTING.md § License and Contributor Agreement](./CONTRIBUTING.md#license-and-contributor-agreement), including ForestHub's right to relicense my contribution commercially in addition to AGPL-3.0. +- [ ] I agree to the terms in [CONTRIBUTING.md § License and Contributor Agreement](./CONTRIBUTING.md#license-and-contributor-agreement). I understand that contributions to `contract/` and `ts/workflow-core/` are released under Apache-2.0, and contributions to all other paths are released under AGPL-3.0-only with ForestHub's right to also offer them under a commercial license. - [ ] My contribution is my own original work and I am legally entitled to submit it under these terms. - [ ] This contribution was created **outside** any employment, work-for-hire, or contractor obligations and **without** the use of employer-owned equipment, accounts, or time — **OR** my employer has explicitly authorized me to submit it under these terms and has waived any rights under §69b UrhG (or equivalent foreign law) for this contribution. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c935f11 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,70 @@ +version: 2 + +updates: + # Go module (root of Go code is /go) + - package-ecosystem: "gomod" + directory: "/go" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + commit-message: + prefix: "chore" + prefix-development: "chore" + include: "scope" + groups: + go-minor-and-patch: + applies-to: version-updates + update-types: + - "minor" + - "patch" + go-security: + applies-to: security-updates + update-types: + - "minor" + - "patch" + + # npm workspace root (handles workflow-core, workflow-builder, app) + - package-ecosystem: "npm" + directory: "/ts" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + commit-message: + prefix: "chore" + prefix-development: "chore" + include: "scope" + groups: + npm-minor-and-patch: + applies-to: version-updates + update-types: + - "minor" + - "patch" + npm-security: + applies-to: security-updates + update-types: + - "minor" + - "patch" + + # GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + commit-message: + prefix: "chore" + include: "scope" + groups: + actions-minor-and-patch: + applies-to: version-updates + update-types: + - "minor" + - "patch" + actions-security: + applies-to: security-updates + update-types: + - "minor" + - "patch" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..3b4bac0 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,51 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly on Sundays at 04:17 UTC. Off-peak, off the hour. + - cron: "17 4 * * 0" + +permissions: + contents: read + actions: read + security-events: write + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # CodeQL's modern "javascript-typescript" identifier covers ts/. + # "go" covers go/ — autobuild finds the module via go/go.mod. + language: [go, javascript-typescript] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + if: matrix.language == 'go' + uses: actions/setup-go@v5 + with: + go-version-file: go/go.mod + cache-dependency-path: go/go.sum + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000..fa30d1e --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,29 @@ +name: gitleaks + +on: + push: + pull_request: + +permissions: + contents: read + pull-requests: write + +jobs: + scan: + name: Scan for leaked secrets + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout (full history) + uses: actions/checkout@v4 + with: + # gitleaks needs the full history to scan past commits, not just + # the diff. Shallow clone would miss anything before the last fetch. + fetch-depth: 0 + + - name: Run gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # No GITLEAKS_LICENSE on purpose: it's a paid-tier key. Public repos + # get full functionality on the free tier without it. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..3303fc6 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,43 @@ +name: Lint + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + go-lint: + name: Go (golangci-lint) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go/go.mod + cache-dependency-path: go/go.sum + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + working-directory: go + args: --timeout=5m + + ts-lint: + name: TS (eslint) + runs-on: ubuntu-latest + defaults: + run: + working-directory: ts + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: ts/package-lock.json + - run: npm ci + - run: npm run lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ff03631 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,116 @@ +name: Release container + +# Builds and publishes the multi-arch engine container to GHCR on every +# `go/vX.Y.Z` tag — the same tag that releases the Go module (see RELEASING.md). +# Image: ghcr.io/foresthubai/edge-agents/engine +# +# Triggered manually via workflow_dispatch for one-off rebuilds, but the +# canonical path is `git tag go/vX.Y.Z && git push origin go/vX.Y.Z`. + +on: + push: + tags: ['go/v*'] + workflow_dispatch: + inputs: + tag: + description: 'Override image tag (default: derived from ref / sha)' + required: false + type: string + +permissions: + contents: read + packages: write + id-token: write # cosign keyless OIDC + provenance attestations + attestations: write # actions/attest-* writes to the attestation store + +env: + REGISTRY: ghcr.io + IMAGE_NAME: foresthubai/edge-agents/engine + +jobs: + build-and-push: + name: Build, push, sign (linux/amd64,arm64) + runs-on: ubuntu-latest + outputs: + digest: ${{ steps.build.outputs.digest }} + tags: ${{ steps.meta.outputs.tags }} + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Strips the `go/` prefix from `refs/tags/go/v1.2.3` so the image gets + # `1.2.3`, `1.2`, `1`, and `latest` rather than literally `go-v1.2.3`. + - name: Compute image tags + labels + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=match,pattern=go/(v.*),group=1 + type=match,pattern=go/v(\d+\.\d+\.\d+),group=1 + type=match,pattern=go/v(\d+\.\d+)\.\d+,group=1 + type=match,pattern=go/v(\d+)\.\d+\.\d+,group=1 + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/go/v') }} + type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }} + labels: | + org.opencontainers.image.title=ForestHub edge-agents engine + org.opencontainers.image.description=Workflow runtime for embedded and edge AI agents. + org.opencontainers.image.vendor=ForestHub + org.opencontainers.image.licenses=AGPL-3.0-only + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.url=https://github.com/${{ github.repository }} + org.opencontainers.image.documentation=https://github.com/${{ github.repository }}#readme + + - name: Build and push + id: build + uses: docker/build-push-action@v6 + with: + context: go + file: go/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: mode=max + sbom: true + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + # Keyless signing — uses the GitHub OIDC token, no long-lived secret. + # Signs by digest so every tag pointing at this build is covered. + - name: Sign image + env: + DIGEST: ${{ steps.build.outputs.digest }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + for tag in $TAGS; do + ref="${tag%:*}@${DIGEST}" + echo "cosign sign $ref" + cosign sign --yes "$ref" + done + + - name: Attest build provenance + uses: actions/attest-build-provenance@v2 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/spectral.yml b/.github/workflows/spectral.yml new file mode 100644 index 0000000..b662e70 --- /dev/null +++ b/.github/workflows/spectral.yml @@ -0,0 +1,31 @@ +name: Spectral + +on: + pull_request: + branches: [main] + paths: + - "contract/**" + - ".spectral.yaml" + - ".github/workflows/spectral.yml" + push: + branches: [main] + paths: + - "contract/**" + - ".spectral.yaml" + - ".github/workflows/spectral.yml" + +permissions: + contents: read + pull-requests: write + checks: write + +jobs: + spectral: + name: Lint OpenAPI contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: stoplightio/spectral-action@v0.8.13 + with: + file_glob: "contract/*.yaml" + spectral_ruleset: .spectral.yaml diff --git a/.gitignore b/.gitignore index 59cbdfd..ccf5965 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ ts/**/dist/ # Go *.test *.out +go/fh-agent +go/build/ +go/dist/ # Engine deploy build artifacts (image tarball produced by `docker save`) *.tar diff --git a/.spectral.yaml b/.spectral.yaml new file mode 100644 index 0000000..d47c47d --- /dev/null +++ b/.spectral.yaml @@ -0,0 +1 @@ +extends: ["spectral:oas"] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2302d71 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,98 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +This repository hosts multiple release lines (Go binaries under `go/`, npm +packages under `ts/`) and each line follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +independently. + +## [Unreleased] + +### Added +- `fh-agent` CLI: compile `site.spec.yaml` into deployable edge-agent bundles. +- `fh-agent` CLI: contract-schema validation via `fh-builder` subprocess. +- `fh-agent`: dedicated `ROADMAP.md` alongside the CLI. +- Dependabot configuration for Go modules, npm packages, and GitHub Actions. +- Repository governance: `CODEOWNERS` for review routing, `FUNDING.yml`. + +### Changed +- README rewritten as a category-defining, SEO/GEO-optimized engineering entry + point; SLM/Local-provider story surfaced; hero tagline, Features section, + ASCII diagram, and branded footer restored. + +### Removed +- Commented-out `resilience.go` stub from `llmproxy`. + +## [go/v1.0.1] - 2026-05-29 + +First tagged Go release after the `fh-core` → `edge-agents` repository rename. + +### Added +- AGPL license header, `NOTICE`, and third-party notices across the tree. +- Initial README and community health files (issue templates, contributing). +- `cmd-engine`: standalone mode (operates without backend) and multi-arch + Dockerfile. +- `cmd-engine`: CI workflow with image-tarball gitignore. +- Backend-routed LLM provider in `engine-backend`. +- `engine`: Lifecycle port (replaces `ControlPlane`); typed ports for LLM / + Memory / Retriever with contract regeneration. +- MQTT channel: topic support; `line`/`channel` promoted to binding fields. +- Go `cmd` package wired back into the builder UI flow. +- Working CLI for the engine. +- Tests: `llmproxy-provider` now reads Vertex AI config from env; coverage + expanded. + +### Changed +- Repository renamed from `fh-core` to `edge-agents`; package layout + flattened. +- `engine`: ticker and JSON-type helpers moved into the `mapping` package; + logging extracted into its own package (`Activity` helper dropped). +- `engine`: memory tests isolated from the backend adapter. +- Workflow YAML: deployment mapping removed. +- Mapping promoted to a dedicated package; `engineapi` dependency removed + from `engine`; mapping folded into `llmproxy`. + +### Fixed +- Lockstep / CI contract-drift check. +- npm lockfile regeneration with optional deps (works around npm bug). + +## [ts/0.1.1] - 2026-05-29 + +First documented release of the TypeScript workspace +(`@foresthubai/workflow-core`, `@foresthubai/workflow-builder`). + +### Added +- Visual workflow builder SPA (`workflow-builder`) with drag-and-drop canvas, + canvas tabs toolbar, and overhauled scrollbars. +- `workflow-core`: workflow schema migration infrastructure; builder + auto-migrates workflows on load. +- Node library: model declared as a resource alongside the catalog; unified + RAG and memory abstractions. +- Function configuration and diagnostics enhancements; function definition + scoped at editor level rather than per-canvas. +- Parameter system overhaul and serialization streamlining across nodes and + channels. +- Localization for toast messages and validation feedback. +- ESLint + Prettier setup across the TS workspace. +- TS release system (publish scripts under `ts/scripts/`). + +### Changed +- `workflow-core` refactored; mapping promoted to its own package. +- Unified UI colors and node-library design; unified name de-duplication and + selection model. +- `isDirty` tracking now driven by `mutationCount` in history. + +### Fixed +- `Parameter.ts` casing corrected to match imports. +- Leaked React dependency in `workflow-core` removed; assorted code-smell + cleanup, type errors, and tests referring to old nodes. + +## [0.0.0] - 2026-05-19 + +Initial commit. Internal prototype prior to the first tagged release. + +[Unreleased]: https://github.com/ForestHubAI/edge-agents/compare/go/v1.0.1...HEAD +[go/v1.0.1]: https://github.com/ForestHubAI/edge-agents/releases/tag/go/v1.0.1 +[ts/0.1.1]: https://github.com/ForestHubAI/edge-agents/tree/main/ts +[0.0.0]: https://github.com/ForestHubAI/edge-agents/commit/d9e6f9d diff --git a/NOTICE b/NOTICE index 9d415f7..6fa1092 100644 --- a/NOTICE +++ b/NOTICE @@ -1,11 +1,23 @@ edge-agents Copyright (c) 2026 ForestHub. All rights reserved. -This product is licensed under the GNU Affero General Public License v3.0 -(AGPL-3.0-only); see the LICENSE file for the full text. +This product includes software developed by ForestHub +(https://foresthub.ai). + +This repository uses a two-tier license model: + + - The contract/ and ts/workflow-core/ subdirectories are licensed under + the Apache License, Version 2.0. See contract/LICENSE and + contract/NOTICE, and ts/workflow-core/LICENSE and + ts/workflow-core/NOTICE for details. + + - All other components (engine, LLM proxy, workflow-builder, app) are + licensed under the GNU Affero General Public License v3.0 + (AGPL-3.0-only); see the LICENSE file for the full AGPL text. edge-agents is dual-licensed. In addition to the AGPL-3.0, ForestHub offers -separate commercial licenses for use cases that are incompatible with the -AGPL. For commercial licensing, contact root@foresthub.ai. +separate commercial licenses for the AGPL components for use cases that are +incompatible with the AGPL. For commercial licensing, contact +root@foresthub.ai. Third-party components retain their original licenses; see THIRD_PARTY_NOTICES. diff --git a/README.md b/README.md index 1c129c7..1757114 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # edge-agents -**The open-source runtime for embedded and edge AI agents.** +**A ~32 MB AI agent runtime for edge devices.** Runs offline. GPIO, MQTT, and UART as first-class nodes. From a Raspberry Pi to industrial gateways. [![CI](https://github.com/ForestHubAI/edge-agents/actions/workflows/ci.yml/badge.svg)](https://github.com/ForestHubAI/edge-agents/actions/workflows/ci.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/ForestHubAI/edge-agents/go.svg)](https://pkg.go.dev/github.com/ForestHubAI/edge-agents/go) +[![Go Version](https://img.shields.io/github/go-mod/go-version/ForestHubAI/edge-agents?filename=go%2Fgo.mod)](go/go.mod) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE) +[![GitHub stars](https://img.shields.io/github/stars/ForestHubAI/edge-agents?style=flat)](https://github.com/ForestHubAI/edge-agents/stargazers) +[![GitHub last commit](https://img.shields.io/github/last-commit/ForestHubAI/edge-agents)](https://github.com/ForestHubAI/edge-agents/commits) +[![GitHub issues](https://img.shields.io/github/issues/ForestHubAI/edge-agents)](https://github.com/ForestHubAI/edge-agents/issues) `edge-agents` is the open-source core of the [ForestHub](https://foresthub.ai) platform. It contains the Go runtime engine, a multi-provider LLM proxy with @@ -36,7 +40,7 @@ not in this repository. - **Contract-typed wire format** — every API generated from `contract/*.yaml` in both Go and TypeScript; CI fails on drift. - **Distroless multi-arch container** — `linux/amd64` and `linux/arm64`, - nonroot, ~15 MB. + nonroot, ~32 MB. ## Contents @@ -52,6 +56,28 @@ not in this repository. Go and TypeScript are independently buildable. Only `contract/` edits touch both sides. +## Why edge-agents + +Most AI agent frameworks assume datacenter GPUs and always-on cloud APIs. +`edge-agents` is built for the other end of the spectrum — embedded Linux on +industrial gateways, SBCs, and edge nodes. Here is how it compares to projects +people often evaluate alongside it. + +| | edge-agents | LangGraph | n8n | Dify | +|---|---|---|---|---| +| Container image (compressed) | **~32 MB** | n/a (Python library) | ~368 MB | ~900 MB | +| Runs offline / air-gapped | first-class (`ENGINE_STANDALONE=true`) | depends on host app | possible, manual telemetry-disable | not designed for it | +| Hardware I/O (GPIO, ADC/DAC/PWM, UART) | first-class engine nodes | no | only via shell-exec workarounds | no | +| MQTT transport | first-class | no | community node | no | +| Visual builder | yes (React Flow, embeddable) | no | yes | yes | +| Local SLMs (`llama.cpp`, `vLLM`, `Ollama`, …) | typed registry, capability routing | via custom code | community nodes | yes | +| License | AGPL-3.0 + commercial | MIT | Sustainable Use License | Apache-2.0 | + +Container sizes were measured against the latest public images on Docker Hub +in May 2026 (n8n: `n8nio/n8n:latest`, Dify: `langgenius/dify-api:main`); the +`edge-agents` figure is the linux/arm64 binary cross-compiled with +`CGO_ENABLED=0 -ldflags='-s -w'` on top of `gcr.io/distroless/static-debian12`. + ## Quickstart ### Engine (Docker) @@ -251,13 +277,21 @@ scope and process. ## License -Dual-licensed: - -- [AGPL-3.0-only](LICENSE) for open-source use — including the AGPL - requirement to make corresponding source available to users who interact - with a modified version over a network. -- A separate **commercial license** for use cases incompatible with the AGPL. - Contact **root@foresthub.ai**. +`edge-agents` uses a **two-tier license model** designed to make the wire +format and the headless workflow model maximally reusable while keeping +the engine and the visual builder protected under copyleft. + +| Component | License | Why | +| --- | --- | --- | +| [`contract/`](contract) (OpenAPI schemas) | **Apache-2.0** | Wire format. Third-party Python, Rust, or Java clients should be free to implement against it. | +| [`ts/workflow-core`](ts/workflow-core) (headless model) | **Apache-2.0** | Workflow model and validation. Same reasoning — should be embeddable into any TypeScript/JavaScript project without copyleft friction. | +| [`go/`](go) (engine, LLM proxy, drivers) | **AGPL-3.0-only** or **commercial** | Keeps hosted "edge-agents as a service" offerings honest. For commercial use cases incompatible with AGPL, contact **root@foresthub.ai**. | +| [`ts/workflow-builder`](ts/workflow-builder) (React canvas) | **AGPL-3.0-only** or **commercial** | Same dual-license terms as the engine. | +| [`ts/app`](ts/app) (reference SPA, not published) | **AGPL-3.0-only** | Reference implementation, not for redistribution. | + +For the AGPL components, the AGPL network clause applies — providing a +modified version over a network requires making the corresponding source +available to users of that service. Third-party components retain their own licenses; see [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES) and [NOTICE](NOTICE). diff --git a/RELEASING.md b/RELEASING.md index 3eeb47f..7fd5bcb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -7,8 +7,8 @@ never collide. | Artifact | Ecosystem | Version source | Consumer pins with | | ----------------------------------- | --------------------- | ------------------------- | ------------------------------------------- | | `github.com/ForestHubAI/edge-agents/go` | Go modules | **git tag** `go/vX.Y.Z` | `go get ...@vX.Y.Z` | -| `@foresthubai/workflow-core` | npm (GitHub Packages) | `version` in package.json | `npm i @foresthubai/workflow-core@X.Y.Z` | -| `@foresthubai/workflow-builder` | npm (GitHub Packages) | `version` in package.json | `npm i @foresthubai/workflow-builder@X.Y.Z` | +| `@foresthubai/workflow-core` | npm (npmjs.org) | `version` in package.json | `npm i @foresthubai/workflow-core@X.Y.Z` | +| `@foresthubai/workflow-builder` | npm (npmjs.org) | `version` in package.json | `npm i @foresthubai/workflow-builder@X.Y.Z` | Go and TS version on **independent cadences**. The two TS packages, however, are **locked to each other** (see below) — they always ship one shared version. @@ -39,33 +39,37 @@ npm run release -- 0.2.0 Each package's `prepublishOnly` rebuilds `dist/` first, so you never publish stale output. -### Registry: GitHub Packages +### Registry: npmjs.org (public) -Both packages carry `publishConfig.registry = https://npm.pkg.github.com`. +Both packages carry `publishConfig.registry = https://registry.npmjs.org` and +`publishConfig.access = public`, so the first `npm publish` ships the scoped +`@foresthubai/*` packages as openly installable on npmjs.org. No token or +`.npmrc` is required on the consumer side — `npm i @foresthubai/workflow-builder@X.Y.Z` +just works. -**To publish**, npm needs a GitHub token with `write:packages`, supplied via `~/.npmrc` -(never commit it): +**To publish**, log in once on the machine that runs the release: -``` -//npm.pkg.github.com/:_authToken=$[Token with write access] +```sh +npm login # interactive — uses the npmjs.org @foresthubai org account +npm whoami # sanity check ``` -**To consume** (e.g. the private FE repo), add `FH_PACKAGES_TOKEN` as env variable and add an `.npmrc` in consumers' root folder: +Then from `ts/`: +```sh +npm run release -- X.Y.Z ``` -@foresthubai:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=${FH_PACKAGES_TOKEN} -``` -then `npm i @foresthubai/workflow-builder@X.Y.Z` as normal. See -[`ts/workflow-builder/README.md`](ts/workflow-builder/README.md) for the Tailwind/styles -wiring the consumer must also do. +The release script invokes `npm publish --workspaces`, which honours each +package's `publishConfig` and skips the private `@foresthubai/app`. Two-factor +auth on the npm account is recommended; if enabled, `npm publish` will prompt +for the OTP. + +See [`ts/workflow-builder/README.md`](ts/workflow-builder/README.md) for the +Tailwind/styles wiring the consumer must also do. > **No automated changelog.** Put a one-line "what changed" in the release commit so the > FE maintainer (often future-you) can see why a pinned version moved. -> -> **Going public** keeps the `@foresthubai` scope — you'd own that org on npmjs.com and -> drop the GitHub Packages registry line; the package name does not change. ## Go module — manual tag diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES index 05a518b..0565438 100644 --- a/THIRD_PARTY_NOTICES +++ b/THIRD_PARTY_NOTICES @@ -1,12 +1,17 @@ Third-Party Software Notices ============================ -edge-agents is licensed under the GNU Affero General Public License v3.0 -(AGPL-3.0-only). The third-party components listed below are incorporated into -or distributed with edge-agents and retain their original licenses as noted. None of -these licenses conflict with the distribution of edge-agents under AGPL-3.0-only; the -permissive and weak-copyleft licenses below are all one-way compatible with the -GNU AGPL v3. +edge-agents uses a two-tier license model: + - `contract/` and `ts/workflow-core/` are licensed under Apache-2.0 + - The rest (engine, workflow-builder, app) is licensed under AGPL-3.0-only + or a separate commercial license +See LICENSE, NOTICE, contract/LICENSE, and ts/workflow-core/LICENSE for details. + +The third-party components listed below are incorporated into or distributed +with edge-agents and retain their original licenses as noted. None of these +licenses conflict with the two-tier model above; the permissive and +weak-copyleft licenses below are all one-way compatible with both Apache-2.0 +and the GNU AGPL v3. Per-license obligations satisfied here: * MIT / ISC / BSD-2-Clause / BSD-3-Clause / 0BSD / BlueOak-1.0.0 / Python-2.0 diff --git a/contract/LICENSE b/contract/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/contract/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/contract/NOTICE b/contract/NOTICE new file mode 100644 index 0000000..b89b83b --- /dev/null +++ b/contract/NOTICE @@ -0,0 +1,14 @@ +edge-agents/contract +Copyright (c) 2026 ForestHub. All rights reserved. + +This product includes software developed by +ForestHub (https://foresthub.ai). + +The contract/ directory contains OpenAPI 3.0.3 schemas that define the +wire format of the edge-agents platform. This directory is licensed under +the Apache License, Version 2.0, while the rest of the repository (the +engine, the React workflow builder, and the TypeScript workflow-builder +package) is licensed under AGPL-3.0 or a separate commercial license. + +See the repository LICENSE for AGPL terms and contract/LICENSE for the +Apache terms applicable to this subdirectory. diff --git a/go/.golangci.yml b/go/.golangci.yml new file mode 100644 index 0000000..93d3df3 --- /dev/null +++ b/go/.golangci.yml @@ -0,0 +1,47 @@ +# golangci-lint config — sensible defaults for the edge-agents Go module. +# See https://golangci-lint.run/usage/configuration/ + +run: + timeout: 5m + # Generated files are skipped by linters that respect generation markers, + # but be explicit for the oapi-codegen output. + build-tags: [] + +issues: + exclude-dirs: + - api # generated oapi-codegen output (types.gen.go, server.gen.go) + exclude-rules: + # Test files often have legitimate uses of patterns gosec flags. + - path: _test\.go + linters: + - gosec + - errcheck + max-issues-per-linter: 0 + max-same-issues: 0 + +linters: + disable-all: true + enable: + - govet + - errcheck + - staticcheck + - ineffassign + - unused + - gosimple + - gosec + +linters-settings: + gosec: + excludes: + # Noisy / low-signal for this codebase: + - G104 # errors unhandled (errcheck covers this with better context) + - G304 # file path provided as taint input (legitimate config loading) + - G404 # weak random (we don't do crypto in engine paths) + errcheck: + check-type-assertions: false + check-blank: false + govet: + enable-all: true + disable: + - fieldalignment # too noisy for general code + - shadow # too noisy with err re-use diff --git a/go/cmd/engine/server.go b/go/cmd/engine/server.go index c42f5f2..03e4cb0 100644 --- a/go/cmd/engine/server.go +++ b/go/cmd/engine/server.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/subtle" "net/http" "github.com/ForestHubAI/edge-agents/go/api/engineapi" @@ -50,11 +51,25 @@ func (s *strictServer) Stop(_ context.Context, _ engineapi.StopRequestObject) (e // AuthMiddleware is a strict-handler middleware enforcing the shared agent // secret as a bearer token on every operation. An empty configured secret -// rejects all requests. +// rejects all requests. The token comparison uses crypto/subtle to avoid +// leaking the secret through response-time side channels. +// +// The Healthz operation is exempted so that container-orchestrator +// readiness/liveness probes (k8s, compose, ECS) can hit /healthz without +// being issued the shared secret. The handler discloses only "is the +// runner attached" — no workflow content, no node state. func AuthMiddleware(secret string) engineapi.StrictMiddlewareFunc { - return func(f engineapi.StrictHandlerFunc, _ string) engineapi.StrictHandlerFunc { + want := []byte("Bearer " + secret) + return func(f engineapi.StrictHandlerFunc, operationID string) engineapi.StrictHandlerFunc { + if operationID == "Healthz" { + return f + } return func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - if secret == "" || r.Header.Get("Authorization") != "Bearer "+secret { + got := []byte(r.Header.Get("Authorization")) + // Length differs => unauthorized. Length is not secret, so an + // early return here does not weaken the constant-time check; + // ConstantTimeCompare itself rejects unequal-length inputs. + if secret == "" || len(got) != len(want) || subtle.ConstantTimeCompare(got, want) != 1 { http.Error(w, "unauthorized", http.StatusUnauthorized) return nil, nil } diff --git a/go/cmd/engine/server_test.go b/go/cmd/engine/server_test.go new file mode 100644 index 0000000..9ada035 --- /dev/null +++ b/go/cmd/engine/server_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ForestHubAI/edge-agents/go/api/engineapi" + "github.com/stretchr/testify/assert" +) + +// okHandler is a no-op StrictHandlerFunc used to detect whether the +// AuthMiddleware passed the request through (handler called) or rejected it +// (handler not called, 401 written by the middleware). +func okHandler(called *bool) engineapi.StrictHandlerFunc { + return func(_ context.Context, w http.ResponseWriter, _ *http.Request, _ interface{}) (interface{}, error) { + *called = true + w.WriteHeader(http.StatusOK) + return nil, nil + } +} + +func TestAuthMiddleware_RejectsBadBearer(t *testing.T) { + mw := AuthMiddleware("s3cret") + called := false + handler := mw(okHandler(&called), "Deploy") + + req := httptest.NewRequest(http.MethodPost, "/deploy", nil) + req.Header.Set("Authorization", "Bearer wrong") + rec := httptest.NewRecorder() + + _, err := handler(req.Context(), rec, req, nil) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, called, "downstream handler must not run on bad bearer") +} + +func TestAuthMiddleware_RejectsMissingHeader(t *testing.T) { + mw := AuthMiddleware("s3cret") + called := false + handler := mw(okHandler(&called), "Deploy") + + req := httptest.NewRequest(http.MethodPost, "/deploy", nil) + rec := httptest.NewRecorder() + + _, err := handler(req.Context(), rec, req, nil) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, called) +} + +func TestAuthMiddleware_RejectsEmptyConfiguredSecret(t *testing.T) { + mw := AuthMiddleware("") + called := false + handler := mw(okHandler(&called), "Deploy") + + req := httptest.NewRequest(http.MethodPost, "/deploy", nil) + req.Header.Set("Authorization", "Bearer ") + rec := httptest.NewRecorder() + + _, err := handler(req.Context(), rec, req, nil) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, called) +} + +func TestAuthMiddleware_HealthzBypassesAuth(t *testing.T) { + mw := AuthMiddleware("s3cret") + called := false + // Pass operationID="Healthz" — the middleware must let this through + // without checking the Authorization header so that container + // orchestrators can probe readiness without the shared secret. + handler := mw(okHandler(&called), "Healthz") + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + // Deliberately no Authorization header. + rec := httptest.NewRecorder() + + _, err := handler(req.Context(), rec, req, nil) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, called, "Healthz must reach the downstream handler unauthenticated") +} + +func TestAuthMiddleware_HealthzBypassesEvenWithEmptySecret(t *testing.T) { + // Empty secret normally rejects every request. Healthz must still be + // reachable so a misconfigured engine can be diagnosed via the probe. + mw := AuthMiddleware("") + called := false + handler := mw(okHandler(&called), "Healthz") + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + + _, err := handler(req.Context(), rec, req, nil) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, called) +} + +func TestAuthMiddleware_AcceptsCorrectBearer(t *testing.T) { + mw := AuthMiddleware("s3cret") + called := false + handler := mw(okHandler(&called), "Deploy") + + req := httptest.NewRequest(http.MethodPost, "/deploy", nil) + req.Header.Set("Authorization", "Bearer s3cret") + rec := httptest.NewRecorder() + + _, err := handler(req.Context(), rec, req, nil) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, called) +} diff --git a/go/cmd/fh-agent/README.md b/go/cmd/fh-agent/README.md new file mode 100644 index 0000000..b08d97a --- /dev/null +++ b/go/cmd/fh-agent/README.md @@ -0,0 +1,162 @@ +# fh-agent + +Compile a `site.spec.yaml` into a deploy-ready edge-agent bundle for +Raspberry Pi, NVIDIA Jetson, x86 NUC, ST STM32MP25, or Bosch Rexroth +ctrlX CORE. + +Mental model: this is a **compiler** for edge-AI sites. Source is +`site.spec.yaml`; target is a Docker Compose stack you `scp` to a device +and `docker compose up`. + +``` +site.spec.yaml ─ plan ─▶ build/ ─ validate ─▶ build/ ─ build ─▶ dist// + (durable) (compiled graph, (cross-checks) (compose + mapping, manifest, stack) + local-models) +``` + +## Install + +```sh +cd go +go install ./cmd/fh-agent +``` + +## The four passes + +| Pass | Reads | Writes | Network? | Determ? | +| --- | --- | --- | --- | --- | +| `spec` | site.spec.yaml | (echo + lint) | no | — | +| `plan` | site.spec.yaml + target | build/ | no | **yes** | +| `validate` | build/ | diagnostics | no | yes | +| `build` | build/ | dist/\/ | no | yes | + +`plan` is the heart. It MUST be byte-deterministic for the same spec + +target — otherwise an iterating agent sees diff noise. Stable node ids +(SHA256 of `name|type|disc`), sorted keys, no timestamps. + +## Subcommands + +### Authoring + +- `fh-agent spec init [--out site.spec.yaml]` — emit a commented template +- `fh-agent spec schema [--json]` — emit JSON Schema (draft 2020-12) for + programmatic priming of an LLM agent +- `fh-agent spec validate ` — required fields, dangling refs, + bus/device consistency + +### Introspection (the CLI doubles as docs for agents) + +- `fh-agent targets list` — embedded hardware profiles +- `fh-agent targets describe ` — RAM, GPIO/serial map, supported SLMs + with estimated tok/s +- `fh-agent capabilities` — bus types, device kinds, LLM capabilities +- `fh-agent models suggest --target --capability ` — best-fit + models for the target, RAM-filtered, sorted by smallest fit + +### Compilation + +- `fh-agent plan --target [--out build/]` +- `fh-agent validate ` +- `fh-agent build --name [--out dist/] [--tar]` + +## Hardware targets (v1) + +| ID | Arch | RAM | Accel | Notes | +| --- | --- | --- | --- | --- | +| `rpi5-8gb` | arm64 | 8 GB | none | General-purpose SBC | +| `jetson-orin-nano-8gb` | arm64 | 8 GB | CUDA (40 TOPS) | Vision-language / 7B models | +| `x86-nuc-16gb` | amd64 | 16 GB | Iris Xe iGPU | RAG / heavy reasoning | +| `stm32mp25-1gb` | arm64 | 1 GB | Neural-ART NPU | Sensor fusion + classification | +| `ctrlx-core-arm64` | arm64 | 2 GB | i.MX 8M Plus NPU | Industrial controller, ctrlX OS | + +Profiles live in `targets/*.yaml` and are embedded into the binary. Edit +those files to add models, change estimates, or add targets. + +## Bundle layout + +``` +dist// + compose.yml # engine + llama-server + (optional) mosquitto + agent.workflow.json # binding-free graph + site.mapping.json # channels → platform resources + site.resources.yaml # mqtt brokers etc. + device.manifest.json # gpios/serials of this device + local-models.yaml # local SLM registry + bundle.meta.json # fh-agent metadata + .env.example # API-key template + models/ # weights live here (download separately) + README.md # auto-generated, run instructions +``` + +## How an agent uses this + +``` +$ fh-agent spec init --out site.spec.yaml +$ fh-agent spec schema --json # prime on the format +# (agent interviews user, edits site.spec.yaml) +$ fh-agent spec validate site.spec.yaml # exit 1 + JSON diags +# (agent fixes) +$ fh-agent targets list --json +$ fh-agent targets describe rpi5-8gb --json # RAM, SLMs available +$ fh-agent plan site.spec.yaml --target rpi5-8gb --out build/ +$ fh-agent validate build/ +$ fh-agent build build/ --name muellers-haus --tar +# → dist/muellers-haus/ + dist/muellers-haus.tar.gz +``` + +## Output contract for agents + +- **All commands emit JSON** on stdout when `--json` (default for + introspection/diagnostic commands). Status messages go to stderr. +- **Diagnostics shape** is identical across commands: + ```json + {"severity": "error|warn|info", "category": "spec|plan|validate|build", + "message": "...", "location": "$.devices[2].bus.topic", "nodeId": "..."} + ``` +- **Exit codes**: + - `0` ok + - `1` diagnostics (user-correctable) + - `2` infrastructure (IO error, embedded data corrupted) + - `64` usage error +- **Stable output**: same spec + target ⇒ byte-identical artifacts. Map + keys sorted, node ids hashed from inputs, no timestamps. + +## Contract-schema validation + +`fh-agent validate` shells out to `fh-builder validate --json` (the +TypeScript CLI in `ts/app/`) to check the generated workflow against +`contract/workflow.yaml`. The diagnostics merge into the same JSON array +fh-agent's own cross-checks emit. + +To enable, build workflow-core and link fh-builder once: + +```sh +cd ts +npm ci +npm run -w @foresthubai/workflow-core build +cd app && npm link +``` + +If `fh-builder` is not in PATH, `fh-agent validate` emits a single warn +diagnostic and continues — the tool stays usable offline. Pass +`--skip-workflow-check` to silence it. + +## v1 known limits (documented; not bugs) + +- **Bus types** in spec: `mqtt`, `gpio`, `serial`. I2C, SPI, HTTP, and + ctrlX Data-Layer adapters are deferred. +- **No provisioning, no OTA, no HIL**. v1 stops at the bundle. v2 adds + `fh-agent deploy --to pi@host`. +- **Model weights** are not downloaded — `models/` is empty; the README + in the bundle has a hint pointing at Hugging Face for common models. + +## Testing + +```sh +go test ./cmd/fh-agent/... +``` + +Three suites: determinism (same input → same bytes across all 5 +targets), target-fit (chosen model fits target RAM), and validate +catches a mangled bundle. diff --git a/go/cmd/fh-agent/ROADMAP.md b/go/cmd/fh-agent/ROADMAP.md new file mode 100644 index 0000000..10a2086 --- /dev/null +++ b/go/cmd/fh-agent/ROADMAP.md @@ -0,0 +1,76 @@ +# fh-agent roadmap + +The plan for the CLI that turns a `site.spec.yaml` into a deployable +edge-agent bundle. Owned by this directory; updated when scope changes. + +## Principles + +- **Compiler model, not orchestrator.** Source = spec + target; output = + bundle. The CLI does not run the agent or hold state. +- **Deterministic.** Same inputs ⇒ byte-identical outputs. Agents diff + outputs across runs to detect progress. +- **JSON-first.** Every diagnostic and listing is machine-readable. +- **No interactive prompts.** Agents drive this thing. +- **Resilient.** Optional tools (fh-builder, docker) degrade to a warn — + the binary stays usable on a customer device with nothing but glibc. + +## Shipped + +### v1.0 — `site.spec.yaml` → deployable bundle (commit `b4a3150`) + +- Four-phase pipeline: `spec` / `plan` / `validate` / `build` +- 5 embedded hardware target profiles + (`rpi5-8gb`, `jetson-orin-nano-8gb`, `x86-nuc-16gb`, `stm32mp25-1gb`, + `ctrlx-core-arm64`) +- Spec schema + JSON-Schema export for agent self-priming +- Deterministic plan compile (hashed node ids, sorted keys) +- Model auto-selection (capability ∩ RAM fit) per target +- Compose-stack bundle: official engine image + llama-server sidecar + + optional mosquitto + configs + auto-README + `.env.example` + + optional `.tar.gz` +- Cross-validation: channels ↔ mapping ↔ resources ↔ manifest +- Go-side tests: determinism on all 5 targets, RAM-fit, mangle-catch + +### v1.1 — Contract-schema validation (commit `0fb4ef9`) + +- `fh-builder validate --json` (TS) emits machine-readable diagnostics +- Subprocess integration in `fh-agent validate`; warn-fallback if + `fh-builder` is missing +- Plan fixes uncovered by the new check (Ticker units, pinReference, + agentTask edge `prompt`, expression literal defaults) + +## Open — v1.x (hardening, small surface) + +Ordered by ratio of (value / effort). Pick from the top. + +| | Why it matters | Sketch | +| --- | --- | --- | +| **Real-device smoke test on a Pi5** | Every v1 guarantee is theoretical until one bundle has actually booted on real hardware. | Take the muellers-haus example, scp, `docker compose up`, attach a fake MQTT publisher, watch the agent loop. | +| **CI: `go test ./cmd/fh-agent/...`** | Determinism + RAM-fit + validate-catch already exist as tests; just wire them into `.github/workflows/`. | Add a step to the existing CI workflow file. | +| **Claude skill wrapper in `skills/agent-build/`** | Makes the CLI discoverable to Claude Code (mirrors how `workflow-validate` wraps `fh-workflow validate`). | One `SKILL.md`, points at `fh-agent plan / validate / build`. | +| **`fh-agent doctor`** | Pre-flight check on the bundle host: engine reachable, llm endpoint up, GPIO permissions, model file present. Saves support roundtrips. | New subcommand; reuses `loadMetadata` + a few `http.Get` / `os.Stat`. | +| **Model-download helper** | Today `models/` ships empty and the README points at Hugging Face manually. Either an init-container in the compose stack, or `fh-agent build --pull-model`. | Curated GGUF URL table per target catalog; SHA verification. | +| **Spec-schema JSON publishable** | The schema export is the primary primer for an external Claude agent. Ship it as a versioned URL (e.g. `https://schemas.foresthub.ai/site.spec.v1.json`) so prompts can reference it stably. | Wire `fh-agent spec schema` into a build artifact, host on cloudflare. | + +## Open — v2 (new phases, bigger scope) + +| Phase | What it unlocks | +| --- | --- | +| **`fh-agent deploy --to pi@host`** | Closes the manual gap between bundle and running agent. SSH/scp or Balena, then `docker compose up` remotely. Bring-your-own-key auth. | +| **HIL test harness** | `scenarios.test.yaml` + a runner that publishes synthetic MQTT events, waits, asserts on actuator topics. Safety-critical for buildings (heating must not switch off). The skeleton file already ships in v1 bundles. | +| **Bus-type expansion: I2C / SPI / HTTP / ctrlX Data-Layer** | Unlocks STM32MP25 ADC/DAC and full ctrlX CORE use-cases. Requires matching channel types in `contract/workflow.yaml`. | +| **Constraint enforcement** | Today, `constraints[]` flow into the agent prompt as text. v2 generates structural guards (`If` nodes with min/max) in front of each `WritePin` / `MqttPublish` so the agent *can't* violate them. | +| **Diff-deploy / OTA** | Spec edit ⇒ only the delta is pushed to the engine. Needs `fh-backend` and engine versioning protocol. | +| **Vision + function-call capabilities** | Real tool schemas (not just descriptions), vision-language models for camera-fed sensors. New target capability flags. | +| **More targets** | Variscite VAR-SOM, Toradex Verdin, Siemens IPC, BeagleY-AI. Each is a single YAML in `targets/`. | + +## Out of scope + +Intentionally not in this CLI — belongs elsewhere: + +- **Workflow editing UI** — that is `fh-builder open`. +- **Multi-tenant control plane** — `fh-backend`, closed source. +- **Agent runtime tracing** — engine emits structured logs; consume them + from `fh-backend` or a Grafana dashboard, not from this CLI. +- **Natural-language → spec generation** — that is the agent's job. The + CLI compiles what the agent produced. diff --git a/go/cmd/fh-agent/bundle.go b/go/cmd/fh-agent/bundle.go new file mode 100644 index 0000000..38f96dc --- /dev/null +++ b/go/cmd/fh-agent/bundle.go @@ -0,0 +1,281 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "embed" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "text/template" +) + +//go:embed templates/*.tmpl +var tmplFS embed.FS + +// runBuild assembles a deployable bundle directory from a planned build/. +// Renders compose.yml + README.md + .env.example from templates, copies the +// build artifacts in place, optionally tars the result. +func runBuild(args []string) { + fs := flag.NewFlagSet("build", flag.ExitOnError) + name := fs.String("name", "", "site/bundle name (used as dist subdirectory)") + out := fs.String("out", "dist", "dist directory") + tarOut := fs.Bool("tar", false, "additionally write a .tar.gz of the bundle next to it") + pos := parseMixed(fs, args) + if len(pos) < 1 { + die(exitUsage, "usage: fh-agent build --name [--out dist/] [--tar]") + } + if *name == "" { + die(exitUsage, "--name is required") + } + buildDir := pos[0] + + meta, mdiags := loadMetadata(filepath.Join(buildDir, "bundle.meta.json")) + if hasError(mdiags) { + emitDiags(os.Stderr, mdiags) + os.Exit(exitDiagnostics) + } + + target, err := getTarget(meta.TargetID) + if err != nil { + die(exitDiagnostics, "%v", err) + } + + // Inspect workflow to decide which sidecars compose needs. + wf, wfdiags := loadWorkflow(filepath.Join(buildDir, "agent.workflow.json")) + if hasError(wfdiags) { + emitDiags(os.Stderr, wfdiags) + os.Exit(exitDiagnostics) + } + needs := inspectWorkflowNeeds(wf) + + bundleDir := filepath.Join(*out, *name) + if err := os.MkdirAll(bundleDir, 0o755); err != nil { + die(exitInfra, "mkdir %s: %v", bundleDir, err) + } + + // 1. Copy generated artifacts as-is. + artifacts := []string{ + "agent.workflow.json", + "site.mapping.json", + "site.resources.yaml", + "device.manifest.json", + "local-models.yaml", + "bundle.meta.json", + } + for _, f := range artifacts { + src := filepath.Join(buildDir, f) + dst := filepath.Join(bundleDir, f) + if err := copyFile(src, dst); err != nil { + die(exitInfra, "copy %s: %v", f, err) + } + } + + // 2. Render compose.yml, README.md, .env.example from templates. + ctx := composeContext{ + Name: *name, + Arch: target.Arch, + TargetID: target.ID, + TargetArch: target.Arch, + AccelType: target.Accel.Type, + AccelDevice: target.Accel.Device, + SLMImage: target.SLMRuntime.Image, + SLMPort: target.SLMRuntime.ServePort, + GPURequest: target.SLMRuntime.GPURequest, + ModelID: meta.ChosenModel.ID, + ModelRAMMB: meta.ChosenModel.RAMMB, + ModelDownloadHint: modelDownloadHint(meta.ChosenModel.ID), + NeedsGPIO: needs.gpio, + NeedsSerial: needs.serial, + NeedsMQTT: needs.mqtt, + } + if err := renderTemplate("templates/compose.yml.tmpl", filepath.Join(bundleDir, "compose.yml"), ctx); err != nil { + die(exitInfra, "render compose.yml: %v", err) + } + if err := renderTemplate("templates/readme.md.tmpl", filepath.Join(bundleDir, "README.md"), ctx); err != nil { + die(exitInfra, "render README.md: %v", err) + } + if err := renderTemplate("templates/env.example.tmpl", filepath.Join(bundleDir, ".env.example"), ctx); err != nil { + die(exitInfra, "render .env.example: %v", err) + } + if needs.mqtt { + if err := renderTemplate("templates/mosquitto.conf.tmpl", filepath.Join(bundleDir, "mosquitto.conf"), ctx); err != nil { + die(exitInfra, "render mosquitto.conf: %v", err) + } + } + if err := os.MkdirAll(filepath.Join(bundleDir, "models"), 0o755); err != nil { + die(exitInfra, "mkdir models/: %v", err) + } + keepPath := filepath.Join(bundleDir, "models", ".gitkeep") + _ = os.WriteFile(keepPath, []byte{}, 0o644) + + fmt.Fprintf(os.Stderr, "built bundle at %s\n", bundleDir) + + if *tarOut { + tarPath := bundleDir + ".tar.gz" + if err := tarGz(bundleDir, tarPath); err != nil { + die(exitInfra, "tar: %v", err) + } + fmt.Fprintf(os.Stderr, "wrote %s\n", tarPath) + } + + // Echo summary on stdout as JSON so an agent can parse it. + summary := map[string]any{ + "bundleDir": bundleDir, + "name": *name, + "target": target.ID, + "arch": target.Arch, + "model": meta.ChosenModel.ID, + "sidecars": sidecarList(needs), + "tar": *tarOut, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + _ = enc.Encode(summary) +} + +type composeContext struct { + Name string + Arch string + TargetID string + TargetArch string + AccelType string + AccelDevice string + SLMImage string + SLMPort int + GPURequest string + ModelID string + ModelRAMMB int + ModelDownloadHint string + NeedsGPIO bool + NeedsSerial bool + NeedsMQTT bool +} + +type workflowNeeds struct { + gpio, serial, mqtt bool +} + +func inspectWorkflowNeeds(wf Workflow) workflowNeeds { + var n workflowNeeds + for _, ch := range wf.Channels { + switch ch["type"] { + case "GPIOIN", "GPIOOUT", "PWM", "ADC", "DAC": + n.gpio = true + case "UART": + n.serial = true + case "MQTT": + n.mqtt = true + } + } + return n +} + +func sidecarList(n workflowNeeds) []string { + out := []string{"engine", "llm"} + if n.mqtt { + out = append(out, "mosquitto") + } + return out +} + +// modelDownloadHint returns a best-guess pointer for where to fetch the +// model weights. Best-effort only — a curated registry is a v2 improvement. +func modelDownloadHint(id string) string { + id = strings.ToLower(id) + switch { + case strings.Contains(id, "llama-3.2-1b"): + return "huggingface.co/lmstudio-community/Llama-3.2-1B-Instruct-GGUF" + case strings.Contains(id, "llama-3.2-3b"): + return "huggingface.co/lmstudio-community/Llama-3.2-3B-Instruct-GGUF" + case strings.Contains(id, "llama-3.1-8b"): + return "huggingface.co/lmstudio-community/Meta-Llama-3.1-8B-Instruct-GGUF" + case strings.Contains(id, "qwen2.5"): + return "huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF" + case strings.Contains(id, "tinyllama"): + return "huggingface.co/TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF" + case strings.Contains(id, "nomic-embed"): + return "huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF" + } + return "" +} + +func renderTemplate(srcRel, dst string, ctx any) error { + raw, err := tmplFS.ReadFile(srcRel) + if err != nil { + return fmt.Errorf("read template %s: %w", srcRel, err) + } + t, err := template.New(filepath.Base(srcRel)).Parse(string(raw)) + if err != nil { + return fmt.Errorf("parse template %s: %w", srcRel, err) + } + f, err := os.Create(dst) + if err != nil { + return fmt.Errorf("create %s: %w", dst, err) + } + defer f.Close() + if err := t.Execute(f, ctx); err != nil { + return fmt.Errorf("execute template %s: %w", srcRel, err) + } + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +func tarGz(srcDir, dstPath string) error { + out, err := os.Create(dstPath) + if err != nil { + return err + } + defer out.Close() + gz := gzip.NewWriter(out) + defer gz.Close() + tw := tar.NewWriter(gz) + defer tw.Close() + + root := filepath.Clean(srcDir) + base := filepath.Base(root) + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(root, path) + hdr, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + hdr.Name = filepath.Join(base, rel) + if err := tw.WriteHeader(hdr); err != nil { + return err + } + if info.Mode().IsRegular() { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + if _, err := io.Copy(tw, f); err != nil { + return err + } + } + return nil + }) +} diff --git a/go/cmd/fh-agent/diag.go b/go/cmd/fh-agent/diag.go new file mode 100644 index 0000000..f339f7d --- /dev/null +++ b/go/cmd/fh-agent/diag.go @@ -0,0 +1,53 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" +) + +// Diagnostic is the structured-error shape every fh-agent subcommand emits. +// Same fields, same JSON tags as fh-workflow validate, so agents only need +// one parser across the whole CLI surface. +type Diagnostic struct { + Severity string `json:"severity"` // error | warn | info + Category string `json:"category"` // spec | plan | validate | build + Message string `json:"message"` + Location string `json:"location,omitempty"` // JSONPath into the offending file + NodeID string `json:"nodeId,omitempty"` // workflow node id when relevant +} + +// hasError returns true if any diagnostic is severity=error. +func hasError(ds []Diagnostic) bool { + for _, d := range ds { + if d.Severity == "error" { + return true + } + } + return false +} + +// emitDiags writes diagnostics as a JSON array on stderr. Caller picks the +// exit code — usually 1 if hasError, 0 otherwise. +func emitDiags(w io.Writer, ds []Diagnostic) { + if ds == nil { + ds = []Diagnostic{} + } + buf, _ := json.MarshalIndent(ds, "", " ") + fmt.Fprintln(w, string(buf)) +} + +// Exit codes are part of the CLI contract — agents key off them. Document +// any change in the README. +const ( + exitOK = 0 + exitDiagnostics = 1 // user-correctable: spec error, validation finding + exitInfra = 2 // engine unreachable, missing toolchain, IO failure + exitUsage = 64 +) + +func die(code int, format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(code) +} diff --git a/go/cmd/fh-agent/examples/building-automation.site.yaml b/go/cmd/fh-agent/examples/building-automation.site.yaml new file mode 100644 index 0000000..e917263 --- /dev/null +++ b/go/cmd/fh-agent/examples/building-automation.site.yaml @@ -0,0 +1,90 @@ +schemaVersion: 1 +name: muellers-haus +target: rpi5-8gb +description: | + Single-family home, 2 zones. MQTT broker on local network exposes + temperature sensors and a heating-setpoint topic. One GPIO line drives a + hallway light. + +goals: + - id: comfort + description: Keep occupied rooms comfortable. + - id: energy + description: Reduce energy use when nobody is around. + +constraints: + - id: heating-min + description: Heating setpoint must never drop below 5 °C (frost protection). + - id: heating-max + description: Heating setpoint must never exceed 24 °C. + +zones: + - id: livingroom + name: Wohnzimmer + - id: bedroom + name: Schlafzimmer + +buses: + - id: home-mqtt + type: mqtt + brokerUrl: tcp://localhost:1883 + - id: gpio + type: gpio + +devices: + - id: temp-livingroom + zone: livingroom + kind: sensor + measures: temperature + unit: C + bus: + ref: home-mqtt + topic: home/livingroom/temperature + - id: temp-bedroom + zone: bedroom + kind: sensor + measures: temperature + unit: C + bus: + ref: home-mqtt + topic: home/bedroom/temperature + - id: presence-livingroom + zone: livingroom + kind: sensor + measures: presence + bus: + ref: home-mqtt + topic: home/livingroom/presence + - id: heating-livingroom + zone: livingroom + kind: actuator + controls: heating + unit: C + bus: + ref: home-mqtt + topic: home/livingroom/heating/setpoint + writable: true + - id: heating-bedroom + zone: bedroom + kind: actuator + controls: heating + unit: C + bus: + ref: home-mqtt + topic: home/bedroom/heating/setpoint + writable: true + - id: hallway-light + kind: actuator + controls: light + bus: + ref: gpio + line: 17 + writable: true + +agent: + reasoning: + capability: chat + promptHint: | + Be conservative — when in doubt, leave the setpoint where it is. + Only change actuators when sensor readings clearly call for it. + evaluationEvery: 60s diff --git a/go/cmd/fh-agent/main.go b/go/cmd/fh-agent/main.go new file mode 100644 index 0000000..af7d482 --- /dev/null +++ b/go/cmd/fh-agent/main.go @@ -0,0 +1,272 @@ +// Command fh-agent compiles a site.spec.yaml into a deploy-ready +// edge-agents bundle (workflow + mapping + resources + device manifest + +// local-models config + compose stack). +// +// Pipeline: spec → plan → validate → build. +// +// All subcommands emit structured JSON on --json and exit non-zero on +// diagnostics. The contract is documented in this command's README. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" +) + +const usage = `fh-agent — compile a site.spec.yaml into a deployable edge-agent bundle. + +Authoring (read/write files only): + fh-agent spec init [--out site.spec.yaml] + fh-agent spec schema [--json] + fh-agent spec validate [--json] + +Introspection (read-only, embedded data): + fh-agent targets list [--json] + fh-agent targets describe [--json] + fh-agent capabilities [--json] + fh-agent models suggest --target --capability [--max-ram-mb N] [--json] + +Compilation: + fh-agent plan --target --out + fh-agent validate [--json] + fh-agent build --name --out [--tar] + +Exit codes: + 0 ok + 1 diagnostics (spec/plan/validate finding the user must fix) + 2 infrastructure (IO error, embedded data corrupted) + 64 usage error +` + +func main() { + if len(os.Args) < 2 { + fmt.Fprint(os.Stderr, usage) + os.Exit(exitUsage) + } + cmd := os.Args[1] + args := os.Args[2:] + switch cmd { + case "spec": + runSpec(args) + case "targets": + runTargets(args) + case "capabilities": + runCapabilities(args) + case "models": + runModels(args) + case "plan": + runPlan(args) + case "validate": + runValidate(args) + case "build": + runBuild(args) + case "-h", "--help", "help": + fmt.Print(usage) + case "version", "--version", "-v": + fmt.Println("fh-agent v0.1.0") + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n\n%s", cmd, usage) + os.Exit(exitUsage) + } +} + +// ----- spec subcommand ----- + +func runSpec(args []string) { + if len(args) == 0 { + die(exitUsage, "spec subcommand required (init|schema|validate)") + } + switch args[0] { + case "init": + runSpecInit(args[1:]) + case "schema": + runSpecSchema(args[1:]) + case "validate": + runSpecValidate(args[1:]) + default: + die(exitUsage, "unknown spec subcommand: %s", args[0]) + } +} + +func runSpecInit(args []string) { + fs := flag.NewFlagSet("spec init", flag.ExitOnError) + out := fs.String("out", "site.spec.yaml", "destination path") + _ = fs.Parse(args) + if _, err := os.Stat(*out); err == nil { + die(exitInfra, "%s already exists — refusing to overwrite", *out) + } + if err := os.WriteFile(*out, []byte(specInitTemplate), 0o644); err != nil { + die(exitInfra, "write %s: %v", *out, err) + } + fmt.Fprintf(os.Stderr, "wrote %s\n", *out) +} + +func runSpecSchema(args []string) { + fs := flag.NewFlagSet("spec schema", flag.ExitOnError) + asJSON := fs.Bool("json", true, "emit JSON (currently the only format)") + _ = fs.Parse(args) + _ = asJSON // schema is JSON by definition; flag kept for symmetry + os.Stdout.Write(SpecSchema()) +} + +func runSpecValidate(args []string) { + fs := flag.NewFlagSet("spec validate", flag.ExitOnError) + _ = fs.Bool("json", true, "emit JSON diagnostics") + pos := parseMixed(fs, args) + if len(pos) < 1 { + die(exitUsage, "usage: fh-agent spec validate ") + } + s, err := LoadSpec(pos[0]) + if err != nil { + emitDiags(os.Stderr, []Diagnostic{{Severity: "error", Category: "spec", Message: err.Error()}}) + os.Exit(exitDiagnostics) + } + diags := ValidateSpec(s) + emitDiags(os.Stderr, diags) + if hasError(diags) { + os.Exit(exitDiagnostics) + } +} + +// ----- targets subcommand ----- + +func runTargets(args []string) { + if len(args) == 0 { + die(exitUsage, "targets subcommand required (list|describe)") + } + switch args[0] { + case "list": + runTargetsList(args[1:]) + case "describe": + runTargetsDescribe(args[1:]) + default: + die(exitUsage, "unknown targets subcommand: %s", args[0]) + } +} + +func runTargetsList(args []string) { + fs := flag.NewFlagSet("targets list", flag.ExitOnError) + asJSON := fs.Bool("json", false, "emit JSON") + _ = fs.Parse(args) + ts, err := listTargets() + if err != nil { + die(exitInfra, "load targets: %v", err) + } + if *asJSON { + type row struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Arch string `json:"arch"` + RAMMB int `json:"ramMB"` + Accel string `json:"accel"` + } + out := make([]row, 0, len(ts)) + for _, t := range ts { + out = append(out, row{t.ID, t.DisplayName, t.Arch, t.RAM.TotalMB, t.Accel.Type}) + } + writeJSON(os.Stdout, out) + return + } + for _, t := range ts { + fmt.Printf("%-26s %-8s %5d MB accel=%-6s %s\n", t.ID, t.Arch, t.RAM.TotalMB, t.Accel.Type, t.DisplayName) + } +} + +func runTargetsDescribe(args []string) { + fs := flag.NewFlagSet("targets describe", flag.ExitOnError) + _ = fs.Bool("json", true, "emit JSON (only format in v1)") + pos := parseMixed(fs, args) + if len(pos) < 1 { + die(exitUsage, "usage: fh-agent targets describe ") + } + t, err := getTarget(pos[0]) + if err != nil { + die(exitDiagnostics, "%v", err) + } + writeJSON(os.Stdout, t) +} + +// ----- capabilities subcommand ----- + +func runCapabilities(args []string) { + fs := flag.NewFlagSet("capabilities", flag.ExitOnError) + _ = fs.Bool("json", true, "emit JSON") + _ = fs.Parse(args) + caps := struct { + BusTypes []string `json:"busTypes"` + DeviceKinds []string `json:"deviceKinds"` + LLMCapabilities []string `json:"llmCapabilities"` + ContractVersion int `json:"contractWorkflowSchemaVersion"` + Notes string `json:"notes"` + }{ + BusTypes: []string{"mqtt", "gpio", "serial"}, + DeviceKinds: []string{"sensor", "actuator", "controller"}, + LLMCapabilities: []string{"chat", "reasoning", "classification", "embedding", "function_call", "vision", "code"}, + ContractVersion: 1, + Notes: "v1 bus types map to engine channel types MQTT, GPIO (digital pin), UART (serial). I2C/SPI/HTTP/CtrlX-Data-Layer planned for later versions.", + } + writeJSON(os.Stdout, caps) +} + +// ----- models subcommand ----- + +func runModels(args []string) { + if len(args) == 0 { + die(exitUsage, "models subcommand required (suggest)") + } + switch args[0] { + case "suggest": + runModelsSuggest(args[1:]) + default: + die(exitUsage, "unknown models subcommand: %s", args[0]) + } +} + +func runModelsSuggest(args []string) { + fs := flag.NewFlagSet("models suggest", flag.ExitOnError) + targetID := fs.String("target", "", "hardware target id") + cap := fs.String("capability", "", "required capability (chat|reasoning|embedding|...)") + maxRAM := fs.Int("max-ram-mb", 0, "optional hard RAM cap; 0 = use target available") + _ = fs.Bool("json", true, "emit JSON") + _ = fs.Parse(args) + if *targetID == "" || *cap == "" { + die(exitUsage, "usage: fh-agent models suggest --target --capability ") + } + t, err := getTarget(*targetID) + if err != nil { + die(exitDiagnostics, "%v", err) + } + out := suggestModels(t, *cap, *maxRAM) + writeJSON(os.Stdout, out) +} + +// parseMixed parses args allowing flags and positional args in any order. +// stdlib flag stops at the first non-flag arg; we loop, collect positionals, +// and re-parse the remainder until exhausted. Returns positional args. +func parseMixed(fs *flag.FlagSet, args []string) []string { + var positional []string + for len(args) > 0 { + _ = fs.Parse(args) + rem := fs.Args() + if len(rem) == 0 { + break + } + positional = append(positional, rem[0]) + args = rem[1:] + } + return positional +} + +// writeJSON emits indented, stable JSON to w. All Maps in inputs must have +// string keys (Go's json encoder sorts those alphabetically — gives us +// deterministic output for free). +func writeJSON(w *os.File, v any) { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + die(exitInfra, "encode JSON: %v", err) + } +} diff --git a/go/cmd/fh-agent/plan.go b/go/cmd/fh-agent/plan.go new file mode 100644 index 0000000..caf4c7a --- /dev/null +++ b/go/cmd/fh-agent/plan.go @@ -0,0 +1,761 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// runPlan is the heart of fh-agent: site.spec.yaml × target → build/ with +// five artifacts: agent.workflow.json, site.mapping.json, site.resources.yaml, +// device.manifest.json, local-models.yaml. +// +// MUST be deterministic — same spec + target → byte-identical output. +// Otherwise an iterating agent sees diff noise across re-plans. +func runPlan(args []string) { + fs := flag.NewFlagSet("plan", flag.ExitOnError) + targetID := fs.String("target", "", "hardware target id (overrides spec.target)") + out := fs.String("out", "build", "output directory") + pos := parseMixed(fs, args) + if len(pos) < 1 { + die(exitUsage, "usage: fh-agent plan [--target id] [--out build/]") + } + specPath := pos[0] + + spec, err := LoadSpec(specPath) + if err != nil { + emitDiags(os.Stderr, []Diagnostic{{Severity: "error", Category: "spec", Message: err.Error()}}) + os.Exit(exitDiagnostics) + } + if diags := ValidateSpec(spec); hasError(diags) { + emitDiags(os.Stderr, diags) + os.Exit(exitDiagnostics) + } + if *targetID != "" { + spec.Target = *targetID + } + target, err := getTarget(spec.Target) + if err != nil { + emitDiags(os.Stderr, []Diagnostic{{Severity: "error", Category: "plan", Message: err.Error(), Location: "$.target"}}) + os.Exit(exitDiagnostics) + } + + plan, diags := compile(spec, target) + if hasError(diags) { + emitDiags(os.Stderr, diags) + os.Exit(exitDiagnostics) + } + + if err := os.MkdirAll(*out, 0o755); err != nil { + die(exitInfra, "mkdir %s: %v", *out, err) + } + if err := writePlanArtifacts(*out, plan); err != nil { + die(exitInfra, "write artifacts: %v", err) + } + if len(diags) > 0 { + emitDiags(os.Stderr, diags) + } + fmt.Fprintf(os.Stderr, "planned %s → %s (target=%s, %d devices, %d channels)\n", + specPath, *out, target.ID, len(spec.Devices), len(plan.Workflow.Channels)) +} + +// planResult is the in-memory bundle the compile pass produces. +type planResult struct { + Workflow Workflow + Mapping DeploymentMapping + Resources ExternalResources + DeviceManifest DeviceManifest + LocalModels LocalModelsConfig + BundleMetadata BundleMetadata +} + +// BundleMetadata is fh-agent–specific (not consumed by the engine). Kept in +// the build dir so 'fh-agent build' can render the compose stack without +// re-reading the spec. +type BundleMetadata struct { + SchemaVersion int `json:"schemaVersion" yaml:"schemaVersion"` + SpecName string `json:"specName" yaml:"specName"` + TargetID string `json:"targetId" yaml:"targetId"` + TargetArch string `json:"targetArch" yaml:"targetArch"` + SLMRuntime TargetSLMRT `json:"slmRuntime" yaml:"slmRuntime"` + ChosenModel ChosenModel `json:"chosenModel" yaml:"chosenModel"` +} + +type ChosenModel struct { + ID string `json:"id" yaml:"id"` + RAMMB int `json:"ramMB" yaml:"ramMB"` + GPURequest string `json:"gpuRequest,omitempty" yaml:"gpuRequest,omitempty"` +} + +// ---------- engine-side artifact shapes (subset of contract) ---------- + +// Workflow is the subset of the engine workflow contract we emit. We treat +// node arguments as raw JSON objects so we can hand-craft per node type +// without modelling every field statically. The downstream +// `fh-workflow validate` is the authoritative schema check. +type Workflow struct { + SchemaVersion int `json:"schemaVersion"` + Nodes []map[string]any `json:"nodes"` + Edges []map[string]any `json:"edges"` + Functions []any `json:"functions"` + DeclaredVariables []any `json:"declaredVariables"` + Channels []map[string]any `json:"channels"` + Memory []any `json:"memory"` + Models []map[string]any `json:"models"` +} + +// DeploymentMapping per contract/engine.yaml — keyed by workflow resource id. +type DeploymentMapping map[string]ResourceBinding +type ResourceBinding struct { + Ref string `json:"ref"` + Index *int `json:"index,omitempty"` +} + +// ExternalResources per contract/engine.yaml — keyed by platform resource id. +type ExternalResources map[string]map[string]any + +// DeviceManifest per contract/engine.yaml — keyed by driver instance id. +type DeviceManifest struct { + GPIOs map[string]map[string]any `json:"gpios,omitempty"` + Serials map[string]map[string]any `json:"serials,omitempty"` + PWMs map[string]map[string]any `json:"pwms,omitempty"` + ADCs map[string]map[string]any `json:"adcs,omitempty"` + DACs map[string]map[string]any `json:"dacs,omitempty"` +} + +// LocalModelsConfig is the format the engine's Local LLM provider expects +// (see go/llmproxy README). One endpoint per process, models declared with +// capabilities. +type LocalModelsConfig struct { + Endpoints []LocalEndpoint `yaml:"endpoints"` +} +type LocalEndpoint struct { + URL string `yaml:"url"` + Models []LocalModel `yaml:"models"` +} +type LocalModel struct { + ID string `yaml:"id"` + Capabilities []string `yaml:"capabilities"` + Dimension int `yaml:"dimension,omitempty"` +} + +// ---------- compiler ---------- + +// compile is the deterministic pass spec × target → plan artifacts. +func compile(spec *Spec, target *Target) (planResult, []Diagnostic) { + var diags []Diagnostic + + chosen, suggDiags := pickReasoningModel(spec, target) + diags = append(diags, suggDiags...) + + workflow := Workflow{ + SchemaVersion: 1, + Nodes: []map[string]any{}, + Edges: []map[string]any{}, + Functions: []any{}, + DeclaredVariables: []any{}, + Channels: []map[string]any{}, + Memory: []any{}, + Models: []map[string]any{}, + } + mapping := DeploymentMapping{} + resources := ExternalResources{} + manifest := DeviceManifest{ + GPIOs: map[string]map[string]any{}, + Serials: map[string]map[string]any{}, + PWMs: map[string]map[string]any{}, + ADCs: map[string]map[string]any{}, + DACs: map[string]map[string]any{}, + } + + // Index spec.buses for fast lookup. + busByID := map[string]Bus{} + for _, b := range spec.Buses { + busByID[b.ID] = b + } + + // 1. Model declaration in workflow. Local SLM is referenced by id; the + // Local provider routes via the embedded local-models.yaml. + if chosen.ID != "" { + workflow.Models = append(workflow.Models, map[string]any{ + "type": "LLMModel", + "id": chosen.ID, + "label": chosen.ID, + "capabilities": []string{spec.Agent.Reasoning.Capability}, + }) + } + + // 2. Devices → channels (+ matching mapping/resources/manifest entries). + // Channels are sorted by stable id for reproducible output. + sortedDevices := append([]Device(nil), spec.Devices...) + sort.Slice(sortedDevices, func(i, j int) bool { return sortedDevices[i].ID < sortedDevices[j].ID }) + + for _, d := range sortedDevices { + bus := busByID[d.Bus.Ref] + switch bus.Type { + case "mqtt": + chID := "ch-mqtt-" + sanitize(d.ID) + workflow.Channels = append(workflow.Channels, map[string]any{ + "type": "MQTT", + "id": chID, + "label": d.ID, + "topic": d.Bus.Topic, + }) + // MQTT channels resolve against ExternalResources via mapping.ref. + resID := "mqtt-" + sanitize(bus.ID) + mapping[chID] = ResourceBinding{Ref: resID} + resources[resID] = mqttResource(bus) + + case "gpio": + direction := "GPIOIN" + if d.Bus.Writable || d.Kind == "actuator" { + direction = "GPIOOUT" + } + chID := "ch-gpio-" + sanitize(d.ID) + ch := map[string]any{ + "type": direction, + "id": chID, + "label": d.ID, + } + if direction == "GPIOIN" { + ch["bias"] = "pulldown" + ch["debounceMs"] = 20 + } + workflow.Channels = append(workflow.Channels, ch) + gpioID := pickGPIOChip(target, bus) + line := 0 + if d.Bus.Line != nil { + line = *d.Bus.Line + } + mapping[chID] = ResourceBinding{Ref: gpioID, Index: &line} + manifest.GPIOs[gpioID] = map[string]any{"chip": gpioID} + + case "serial": + chID := "ch-uart-" + sanitize(d.ID) + workflow.Channels = append(workflow.Channels, map[string]any{ + "type": "UART", + "id": chID, + "label": d.ID, + }) + serID := pickSerial(target, bus) + mapping[chID] = ResourceBinding{Ref: serID} + serCfg := map[string]any{"device": serialDevice(target, bus)} + if bus.Baud > 0 { + serCfg["baud"] = bus.Baud + } + manifest.Serials[serID] = serCfg + } + } + + // 3. Nodes — minimal but engine-loadable. Per device-class one node; + // a single Agent driven by Ticker. Layout positions are computed + // from index for stable visual output in fh-builder. + startupID := nodeID(spec.Name, "OnStartup", "boot") + tickerID := nodeID(spec.Name, "Ticker", "agent") + agentID := nodeID(spec.Name, "Agent", "reasoner") + + workflow.Nodes = append(workflow.Nodes, + map[string]any{ + "id": startupID, + "type": "OnStartup", + "position": map[string]any{"x": 0, "y": 0}, + "arguments": map[string]any{}, + }, + map[string]any{ + "id": tickerID, + "type": "Ticker", + "position": map[string]any{"x": 0, "y": 120}, + "arguments": tickerArgs(spec.Agent.EvaluationEvery), + }, + map[string]any{ + "id": agentID, + "type": "Agent", + "position": map[string]any{"x": 300, "y": 60}, + "arguments": map[string]any{ + "name": "site-agent", + "model": chosen.ID, + "instructions": buildAgentInstructions(spec, target), + "maxTurns": 6, + "answer": map[string]any{"active": false, "mode": "emit", "name": "answer"}, + "outputDeclarations": []any{}, + "memoryRefs": []any{}, + }, + }, + ) + + // Boot edge: OnStartup → Agent (one-shot warmup). + workflow.Edges = append(workflow.Edges, controlEdge(startupID, agentID)) + // Periodic edge: Ticker → Agent. + workflow.Edges = append(workflow.Edges, controlEdge(tickerID, agentID)) + + // 4. Per-device IO nodes. + for i, d := range sortedDevices { + bus := busByID[d.Bus.Ref] + y := 240 + i*80 + switch bus.Type { + case "mqtt": + chID := "ch-mqtt-" + sanitize(d.ID) + if d.Kind == "sensor" { + id := nodeID(spec.Name, "OnMqttMessage", d.ID) + dt := inferDataType(d) + workflow.Nodes = append(workflow.Nodes, map[string]any{ + "id": id, + "type": "OnMqttMessage", + "position": map[string]any{"x": 0, "y": y}, + "arguments": map[string]any{ + "channelReference": chID, + "dataType": dt, + "output": map[string]any{"active": true, "mode": "emit", "name": d.ID}, + }, + }) + workflow.Edges = append(workflow.Edges, controlEdge(id, agentID)) + } else if d.Kind == "actuator" { + id := nodeID(spec.Name, "MqttPublish", d.ID) + dt := inferDataType(d) + workflow.Nodes = append(workflow.Nodes, map[string]any{ + "id": id, + "type": "MqttPublish", + "position": map[string]any{"x": 600, "y": y}, + "arguments": map[string]any{ + "channelReference": chID, + "dataType": dt, + "value": literalExpr("", dt), + "qos": 0, + "retain": false, + }, + }) + workflow.Edges = append(workflow.Edges, agentToolEdge(agentID, id, fmt.Sprintf("Write to actuator %q (%s in zone %s)", d.ID, d.Controls, d.Zone))) + } + case "gpio": + chID := "ch-gpio-" + sanitize(d.ID) + if d.Kind == "sensor" { + id := nodeID(spec.Name, "OnPinEdge", d.ID) + workflow.Nodes = append(workflow.Nodes, map[string]any{ + "id": id, + "type": "OnPinEdge", + "position": map[string]any{"x": 0, "y": y}, + "arguments": map[string]any{ + "pinReference": chID, + "edge": "both", + }, + }) + workflow.Edges = append(workflow.Edges, controlEdge(id, agentID)) + } else if d.Kind == "actuator" { + id := nodeID(spec.Name, "WritePin", d.ID) + workflow.Nodes = append(workflow.Nodes, map[string]any{ + "id": id, + "type": "WritePin", + "position": map[string]any{"x": 600, "y": y}, + "arguments": map[string]any{ + "pinReference": chID, + "signalType": "digital", + "value": literalExpr(false, "bool"), + }, + }) + workflow.Edges = append(workflow.Edges, agentToolEdge(agentID, id, fmt.Sprintf("Drive GPIO actuator %q (%s)", d.ID, d.Controls))) + } + case "serial": + chID := "ch-uart-" + sanitize(d.ID) + if d.Kind == "sensor" { + id := nodeID(spec.Name, "OnSerialReceive", d.ID) + workflow.Nodes = append(workflow.Nodes, map[string]any{ + "id": id, + "type": "OnSerialReceive", + "position": map[string]any{"x": 0, "y": y}, + "arguments": map[string]any{ + "portReference": chID, + "output": map[string]any{"active": true, "mode": "emit", "name": d.ID}, + }, + }) + workflow.Edges = append(workflow.Edges, controlEdge(id, agentID)) + } else if d.Kind == "actuator" { + id := nodeID(spec.Name, "SerialWrite", d.ID) + workflow.Nodes = append(workflow.Nodes, map[string]any{ + "id": id, + "type": "SerialWrite", + "position": map[string]any{"x": 600, "y": y}, + "arguments": map[string]any{ + "portReference": chID, + "value": literalExpr("", "string"), + }, + }) + workflow.Edges = append(workflow.Edges, agentToolEdge(agentID, id, fmt.Sprintf("Write to serial actuator %q", d.ID))) + } + } + } + + // 5. Local-models.yaml — one endpoint, one model entry, capability list + // drawn from the target catalog. + local := LocalModelsConfig{ + Endpoints: []LocalEndpoint{ + { + URL: fmt.Sprintf("http://localhost:%d", target.SLMRuntime.ServePort), + Models: []LocalModel{ + { + ID: chosen.ID, + Capabilities: modelCapabilities(target, chosen.ID), + Dimension: modelDimension(target, chosen.ID), + }, + }, + }, + }, + } + + return planResult{ + Workflow: workflow, + Mapping: mapping, + Resources: resources, + DeviceManifest: manifest, + LocalModels: local, + BundleMetadata: BundleMetadata{ + SchemaVersion: 1, + SpecName: spec.Name, + TargetID: target.ID, + TargetArch: target.Arch, + SLMRuntime: target.SLMRuntime, + ChosenModel: ChosenModel{ + ID: chosen.ID, + RAMMB: chosen.RAMMB, + GPURequest: target.SLMRuntime.GPURequest, + }, + }, + }, diags +} + +// pickReasoningModel returns the best-fit SLM for the requested capability +// or an error diag if none fit on the target. +func pickReasoningModel(spec *Spec, target *Target) (TargetSLM, []Diagnostic) { + cands := suggestModels(target, spec.Agent.Reasoning.Capability, 0) + if len(cands) == 0 { + return TargetSLM{}, []Diagnostic{{ + Severity: "error", + Category: "plan", + Message: fmt.Sprintf("no SLM on target %q satisfies capability %q within %d MB available RAM", + target.ID, spec.Agent.Reasoning.Capability, target.RAM.availableMB()), + Location: "$.agent.reasoning.capability", + }} + } + // Prefer the largest model that still fits — better quality than the + // smallest. suggestModels returns smallest-first, so reverse-pick. + return cands[len(cands)-1], nil +} + +func modelCapabilities(t *Target, id string) []string { + for _, m := range t.SLMs { + if m.ID == id { + return append([]string(nil), m.Capabilities...) + } + } + return nil +} + +func modelDimension(t *Target, id string) int { + for _, m := range t.SLMs { + if m.ID == id { + return m.Dimension + } + } + return 0 +} + +// pickGPIOChip returns the chip id either from the bus override or the +// target's first gpiochip. Errors are handled later in validate. +func pickGPIOChip(t *Target, b Bus) string { + if b.Chip != "" { + return b.Chip + } + if len(t.Hardware.GPIOs) > 0 { + return t.Hardware.GPIOs[0].ID + } + return "gpiochip0" +} + +func pickSerial(t *Target, b Bus) string { + if b.Device != "" { + return "uart-" + sanitize(b.ID) + } + if len(t.Hardware.Serials) > 0 { + return t.Hardware.Serials[0].ID + } + return "uart-default" +} + +func serialDevice(t *Target, b Bus) string { + if b.Device != "" { + return b.Device + } + if len(t.Hardware.Serials) > 0 { + return t.Hardware.Serials[0].Device + } + return "/dev/ttyUSB0" +} + +func mqttResource(b Bus) map[string]any { + out := map[string]any{ + "type": "mqtt", + "brokerUrl": b.BrokerURL, + } + if b.Username != "" { + out["username"] = b.Username + } + if b.Password != "" { + out["password"] = b.Password + } + return out +} + +func controlEdge(from, to string) map[string]any { + return map[string]any{ + "id": "e-ctrl-" + shortHash(from+"->"+to), + "type": "control", + "from": map[string]any{"nodeId": from, "port": "ctrl"}, + "to": map[string]any{"nodeId": to, "port": "ctrl"}, + } +} + +// agentToolEdge wires a tool node onto the agent. The contract requires +// every agentTask edge to carry a `prompt` Expression — that is the +// LLM-readable description of what the tool does. +func agentToolEdge(from, to, description string) map[string]any { + return map[string]any{ + "id": "e-tool-" + shortHash(from+"->"+to), + "type": "agentTask", + "from": map[string]any{"nodeId": from, "port": "tool"}, + "to": map[string]any{"nodeId": to, "port": "ctrl"}, + "prompt": literalExpr(description, "string"), + } +} + +// literalExpr builds a workflow.Expression with no variable references — +// a constant value the agent fills in at tool-call time (or that the engine +// uses as a default). +func literalExpr(value any, dataType string) map[string]any { + expr := fmt.Sprintf("%v", value) + if expr == "" { + switch dataType { + case "float", "int": + expr = "0" + case "bool": + expr = "false" + default: + expr = `""` + } + } + return map[string]any{ + "expression": expr, + "references": []any{}, + "dataType": dataType, + } +} + +// tickerArgs splits "60s" / "5m" / "100ms" / "1h" into intervalValue + +// intervalUnit as the contract requires. Falls back to 60 seconds. +func tickerArgs(s string) map[string]any { + if s == "" { + return map[string]any{"intervalValue": 60, "intervalUnit": "seconds"} + } + unit := "seconds" + body := s + switch { + case strings.HasSuffix(s, "ms"): + unit, body = "milliseconds", strings.TrimSuffix(s, "ms") + case strings.HasSuffix(s, "s"): + unit, body = "seconds", strings.TrimSuffix(s, "s") + case strings.HasSuffix(s, "m"): + unit, body = "minutes", strings.TrimSuffix(s, "m") + case strings.HasSuffix(s, "h"): + unit, body = "hours", strings.TrimSuffix(s, "h") + } + n := 0 + for _, c := range body { + if c < '0' || c > '9' { + return map[string]any{"intervalValue": 60, "intervalUnit": "seconds"} + } + n = n*10 + int(c-'0') + } + if n == 0 { + n = 60 + } + return map[string]any{"intervalValue": n, "intervalUnit": unit} +} + +// nodeID derives a stable id from spec name + node type + discriminator. +// Same input → same id; agents can diff plan outputs across runs cleanly. +func nodeID(specName, nodeType, disc string) string { + return nodeType + "_" + shortHash(specName+"|"+nodeType+"|"+disc) +} + +func shortHash(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:6]) +} + +// sanitize maps an arbitrary id into [a-z0-9-] for use in channel/resource +// identifiers. +func sanitize(s string) string { + out := make([]byte, 0, len(s)) + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + out = append(out, byte(r)) + case r == '-', r == '_', r == ' ', r == '.': + out = append(out, '-') + } + } + return string(out) +} + +func parseEvery(s string) int { + if s == "" { + return 60_000 + } + mult := 1000 + body := s + switch { + case strings.HasSuffix(s, "ms"): + mult, body = 1, strings.TrimSuffix(s, "ms") + case strings.HasSuffix(s, "s"): + mult, body = 1000, strings.TrimSuffix(s, "s") + case strings.HasSuffix(s, "m"): + mult, body = 60_000, strings.TrimSuffix(s, "m") + case strings.HasSuffix(s, "h"): + mult, body = 3_600_000, strings.TrimSuffix(s, "h") + } + n := 0 + for _, c := range body { + if c < '0' || c > '9' { + return 60_000 + } + n = n*10 + int(c-'0') + } + if n == 0 { + return 60_000 + } + return n * mult +} + +// inferDataType maps a device's unit/measurement to a workflow DataType. +func inferDataType(d Device) string { + if d.Unit != "" { + return "float" + } + switch d.Measures { + case "temperature", "humidity", "co2", "pressure", "voltage", "current": + return "float" + case "presence", "open", "closed": + return "bool" + case "text", "command": + return "string" + } + switch d.Controls { + case "light", "valve": + return "bool" + } + return "string" +} + +// buildAgentInstructions composes the reasoning agent's system prompt from +// the spec's goals, constraints, devices, and user hint. Deterministic order. +func buildAgentInstructions(spec *Spec, target *Target) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("You are the on-device agent for the site %q running on a %s.\n", spec.Name, target.DisplayName)) + if spec.Description != "" { + sb.WriteString(spec.Description + "\n") + } + if len(spec.Goals) > 0 { + sb.WriteString("\nGoals:\n") + for _, g := range spec.Goals { + sb.WriteString(fmt.Sprintf("- (%s) %s\n", g.ID, g.Description)) + } + } + if len(spec.Constraints) > 0 { + sb.WriteString("\nHard constraints (do not violate):\n") + for _, c := range spec.Constraints { + sb.WriteString(fmt.Sprintf("- (%s) %s\n", c.ID, c.Description)) + } + } + sortedDevs := append([]Device(nil), spec.Devices...) + sort.Slice(sortedDevs, func(i, j int) bool { return sortedDevs[i].ID < sortedDevs[j].ID }) + if len(sortedDevs) > 0 { + sb.WriteString("\nDevices available:\n") + for _, d := range sortedDevs { + role := d.Measures + if d.Kind == "actuator" { + role = d.Controls + } + zone := d.Zone + if zone == "" { + zone = "—" + } + sb.WriteString(fmt.Sprintf("- %s [%s, %s, zone=%s]\n", d.ID, d.Kind, role, zone)) + } + } + if hint := strings.TrimSpace(spec.Agent.Reasoning.PromptHint); hint != "" { + sb.WriteString("\nAdditional guidance:\n") + sb.WriteString(hint) + sb.WriteString("\n") + } + return sb.String() +} + +// ---------- artifact writers (deterministic) ---------- + +func writePlanArtifacts(dir string, p planResult) error { + if err := writeStableJSON(filepath.Join(dir, "agent.workflow.json"), p.Workflow); err != nil { + return err + } + if err := writeStableJSON(filepath.Join(dir, "site.mapping.json"), p.Mapping); err != nil { + return err + } + if err := writeStableJSON(filepath.Join(dir, "device.manifest.json"), p.DeviceManifest); err != nil { + return err + } + if err := writeStableYAML(filepath.Join(dir, "site.resources.yaml"), p.Resources); err != nil { + return err + } + if err := writeStableYAML(filepath.Join(dir, "local-models.yaml"), p.LocalModels); err != nil { + return err + } + if err := writeStableJSON(filepath.Join(dir, "bundle.meta.json"), p.BundleMetadata); err != nil { + return err + } + return nil +} + +// writeStableJSON writes JSON with sorted map keys and stable indentation. +// json.Marshal already sorts map keys; for nested maps we round-trip through +// json.RawMessage to normalize key order recursively. +func writeStableJSON(path string, v any) error { + buf, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("marshal %s: %w", path, err) + } + // Round-trip via decode-into-interface{} → re-marshal, which sorts + // nested map keys (encoding/json sorts only top-level map keys, but + // not deeply-nested maps inside slices of any). + var generic any + if err := json.Unmarshal(buf, &generic); err != nil { + return fmt.Errorf("renormalize %s: %w", path, err) + } + sorted, err := json.MarshalIndent(generic, "", " ") + if err != nil { + return fmt.Errorf("re-marshal %s: %w", path, err) + } + return os.WriteFile(path, append(sorted, '\n'), 0o644) +} + +func writeStableYAML(path string, v any) error { + buf, err := yaml.Marshal(v) + if err != nil { + return fmt.Errorf("marshal %s: %w", path, err) + } + return os.WriteFile(path, buf, 0o644) +} diff --git a/go/cmd/fh-agent/plan_test.go b/go/cmd/fh-agent/plan_test.go new file mode 100644 index 0000000..1909679 --- /dev/null +++ b/go/cmd/fh-agent/plan_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// TestCompileIsDeterministic plans the example spec twice and ensures the +// generated artifacts are byte-identical. Iterating agents rely on this — +// any non-deterministic field (timestamps, random ids, map iteration order) +// would break the workflow. +func TestCompileIsDeterministic(t *testing.T) { + exampleSpec := "examples/building-automation.site.yaml" + for _, targetID := range []string{"rpi5-8gb", "stm32mp25-1gb", "jetson-orin-nano-8gb", "x86-nuc-16gb", "ctrlx-core-arm64"} { + t.Run(targetID, func(t *testing.T) { + a := planTo(t, exampleSpec, targetID) + b := planTo(t, exampleSpec, targetID) + if len(a) != len(b) { + t.Fatalf("artifact count differs across runs: %d vs %d", len(a), len(b)) + } + for name, contentA := range a { + contentB, ok := b[name] + if !ok { + t.Errorf("%s: missing in second run", name) + continue + } + if !bytes.Equal(contentA, contentB) { + t.Errorf("%s: bytes differ across runs (non-deterministic)", name) + } + } + }) + } +} + +// TestCompileFitsTarget plans the example on every embedded target and checks +// the chosen model's RAM fits within the target's available budget. +func TestCompileFitsTarget(t *testing.T) { + targets, err := loadAllTargets() + if err != nil { + t.Fatalf("load targets: %v", err) + } + spec, err := LoadSpec("examples/building-automation.site.yaml") + if err != nil { + t.Fatalf("load spec: %v", err) + } + for id, target := range targets { + t.Run(id, func(t *testing.T) { + plan, diags := compile(spec, target) + if hasError(diags) { + t.Fatalf("compile produced errors: %+v", diags) + } + if plan.BundleMetadata.ChosenModel.RAMMB > target.RAM.availableMB() { + t.Errorf("chosen model %q needs %d MB, target has %d MB available", + plan.BundleMetadata.ChosenModel.ID, + plan.BundleMetadata.ChosenModel.RAMMB, + target.RAM.availableMB()) + } + }) + } +} + +// TestValidateCatchesDanglingMapping mangles a planned bundle by deleting an +// entry from site.resources.yaml — validate must flag it. +func TestValidateCatchesDanglingMapping(t *testing.T) { + exampleSpec := "examples/building-automation.site.yaml" + dir := t.TempDir() + spec, err := LoadSpec(exampleSpec) + if err != nil { + t.Fatalf("load spec: %v", err) + } + target, err := getTarget("rpi5-8gb") + if err != nil { + t.Fatalf("get target: %v", err) + } + plan, diags := compile(spec, target) + if hasError(diags) { + t.Fatalf("compile: %+v", diags) + } + if err := writePlanArtifacts(dir, plan); err != nil { + t.Fatalf("write: %v", err) + } + + // Corrupt: blank out resources file. + if err := os.WriteFile(filepath.Join(dir, "site.resources.yaml"), []byte("{}\n"), 0o644); err != nil { + t.Fatalf("corrupt: %v", err) + } + + // Reload & cross-check manually (we can't call runValidate without + // triggering os.Exit). Same logic, no exit. + wf, _ := loadWorkflow(filepath.Join(dir, "agent.workflow.json")) + mapping, _ := loadMapping(filepath.Join(dir, "site.mapping.json")) + resources, _ := loadResources(filepath.Join(dir, "site.resources.yaml")) + + found := false + for chID, b := range mapping { + var typ string + for _, ch := range wf.Channels { + if ch["id"] == chID { + typ, _ = ch["type"].(string) + break + } + } + if typ == "MQTT" { + if _, ok := resources[b.Ref]; !ok { + found = true + } + } + } + if !found { + t.Error("expected validate logic to detect missing mqtt resource binding, but didn't") + } +} + +// planTo runs compile + writePlanArtifacts and returns the artifacts as +// name→bytes for byte-level comparison. +func planTo(t *testing.T, specPath, targetID string) map[string][]byte { + t.Helper() + spec, err := LoadSpec(specPath) + if err != nil { + t.Fatalf("load spec: %v", err) + } + target, err := getTarget(targetID) + if err != nil { + t.Fatalf("get target: %v", err) + } + plan, diags := compile(spec, target) + if hasError(diags) { + t.Fatalf("compile: %+v", diags) + } + dir := t.TempDir() + if err := writePlanArtifacts(dir, plan); err != nil { + t.Fatalf("write: %v", err) + } + out := map[string][]byte{} + for _, f := range []string{ + "agent.workflow.json", "site.mapping.json", "site.resources.yaml", + "device.manifest.json", "local-models.yaml", "bundle.meta.json", + } { + raw, err := os.ReadFile(filepath.Join(dir, f)) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + // Round-trip JSON through json.RawMessage to normalize whitespace — + // catches accidental non-determinism in the writer itself. + if filepath.Ext(f) == ".json" { + var v any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("parse %s: %v", f, err) + } + } + out[f] = raw + } + return out +} + +// TestSpecSchemaIsValidJSON sanity-checks the hand-written schema parses. +func TestSpecSchemaIsValidJSON(t *testing.T) { + var v any + if err := json.Unmarshal(SpecSchema(), &v); err != nil { + t.Fatalf("SpecSchema is not valid JSON: %v", err) + } +} diff --git a/go/cmd/fh-agent/spec.go b/go/cmd/fh-agent/spec.go new file mode 100644 index 0000000..2b03bdd --- /dev/null +++ b/go/cmd/fh-agent/spec.go @@ -0,0 +1,405 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Spec is the user-facing site description. It is the single durable artifact +// the user maintains; every other build artifact is regenerated from it. +type Spec struct { + SchemaVersion int `yaml:"schemaVersion" json:"schemaVersion"` + Name string `yaml:"name" json:"name"` + Target string `yaml:"target" json:"target"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Goals []Goal `yaml:"goals,omitempty" json:"goals,omitempty"` + Constraints []Constraint `yaml:"constraints,omitempty" json:"constraints,omitempty"` + Zones []Zone `yaml:"zones,omitempty" json:"zones,omitempty"` + Buses []Bus `yaml:"buses" json:"buses"` + Devices []Device `yaml:"devices" json:"devices"` + Agent Agent `yaml:"agent" json:"agent"` +} + +type Goal struct { + ID string `yaml:"id" json:"id"` + Description string `yaml:"description" json:"description"` +} + +type Constraint struct { + ID string `yaml:"id" json:"id"` + Description string `yaml:"description" json:"description"` +} + +type Zone struct { + ID string `yaml:"id" json:"id"` + Name string `yaml:"name" json:"name"` +} + +// Bus describes a transport reachable from the site (MQTT broker, GPIO chip, +// serial port). Devices reference a bus by id. +type Bus struct { + ID string `yaml:"id" json:"id"` + Type string `yaml:"type" json:"type"` // mqtt | gpio | serial + BrokerURL string `yaml:"brokerUrl,omitempty" json:"brokerUrl,omitempty"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` + Chip string `yaml:"chip,omitempty" json:"chip,omitempty"` // gpio: overrides target default + Device string `yaml:"device,omitempty" json:"device,omitempty"` // serial: overrides target default + Baud int `yaml:"baud,omitempty" json:"baud,omitempty"` +} + +type Device struct { + ID string `yaml:"id" json:"id"` + Zone string `yaml:"zone,omitempty" json:"zone,omitempty"` + Kind string `yaml:"kind" json:"kind"` // sensor | actuator | controller + Measures string `yaml:"measures,omitempty" json:"measures,omitempty"` // sensors: temperature, humidity, co2, presence, ... + Controls string `yaml:"controls,omitempty" json:"controls,omitempty"` // actuators: heating, cooling, light, valve, ... + Unit string `yaml:"unit,omitempty" json:"unit,omitempty"` + Bus DeviceBus `yaml:"bus" json:"bus"` +} + +// DeviceBus binds a device to one of the spec's buses with the per-device +// addressing (mqtt topic, gpio line number, etc). +type DeviceBus struct { + Ref string `yaml:"ref" json:"ref"` // buses[].id + Topic string `yaml:"topic,omitempty" json:"topic,omitempty"` + Line *int `yaml:"line,omitempty" json:"line,omitempty"` // gpio line / pwm channel + Writable bool `yaml:"writable,omitempty" json:"writable,omitempty"` +} + +type Agent struct { + Reasoning AgentReasoning `yaml:"reasoning" json:"reasoning"` + EvaluationEvery string `yaml:"evaluationEvery,omitempty" json:"evaluationEvery,omitempty"` // simple "60s" / "5m" +} + +type AgentReasoning struct { + Capability string `yaml:"capability" json:"capability"` // chat | reasoning | classification | embedding + PromptHint string `yaml:"promptHint,omitempty" json:"promptHint,omitempty"` +} + +// LoadSpec reads + parses a site.spec.yaml in strict mode (unknown fields +// fail loudly — agents would otherwise emit silently-ignored typos). +func LoadSpec(path string) (*Spec, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read spec: %w", err) + } + dec := yaml.NewDecoder(bytes.NewReader(raw)) + dec.KnownFields(true) + var s Spec + if err := dec.Decode(&s); err != nil { + return nil, fmt.Errorf("parse spec: %w", err) + } + return &s, nil +} + +// SpecSchema returns a hand-written JSON Schema (draft 2020-12). Kept in code +// rather than generated so the schema and the Go types are co-located and +// drift between them shows up in PR review. +func SpecSchema() json.RawMessage { + return json.RawMessage(specSchemaJSON) +} + +// ValidateSpec returns structured diagnostics describing missing required +// fields, dangling refs, and bus/device inconsistencies. Returns nil if the +// spec is well-formed (validation against the target catalog happens later +// in the plan pass). +func ValidateSpec(s *Spec) []Diagnostic { + var diags []Diagnostic + add := func(sev, msg, loc string) { + diags = append(diags, Diagnostic{Severity: sev, Category: "spec", Message: msg, Location: loc}) + } + + if s.SchemaVersion != 1 { + add("error", "schemaVersion must be 1", "$.schemaVersion") + } + if s.Name == "" { + add("error", "name is required", "$.name") + } + if s.Target == "" { + add("error", "target is required", "$.target") + } + if len(s.Buses) == 0 { + add("error", "at least one bus is required", "$.buses") + } + if len(s.Devices) == 0 { + add("error", "at least one device is required", "$.devices") + } + if s.Agent.Reasoning.Capability == "" { + add("error", "agent.reasoning.capability is required (chat|reasoning|classification|embedding)", "$.agent.reasoning.capability") + } + + busByID := map[string]Bus{} + for i, b := range s.Buses { + loc := fmt.Sprintf("$.buses[%d]", i) + if b.ID == "" { + add("error", "bus id is required", loc+".id") + continue + } + if _, dup := busByID[b.ID]; dup { + add("error", fmt.Sprintf("duplicate bus id %q", b.ID), loc+".id") + } + busByID[b.ID] = b + switch b.Type { + case "mqtt": + if b.BrokerURL == "" { + add("error", "mqtt bus requires brokerUrl", loc+".brokerUrl") + } + case "gpio": + // chip defaults to target's first gpiochip; nothing required + case "serial": + // device defaults to target's first serial; nothing required + case "": + add("error", "bus type is required (mqtt|gpio|serial)", loc+".type") + default: + add("error", fmt.Sprintf("unknown bus type %q (mqtt|gpio|serial)", b.Type), loc+".type") + } + } + + zoneByID := map[string]bool{} + for _, z := range s.Zones { + zoneByID[z.ID] = true + } + + devByID := map[string]bool{} + for i, d := range s.Devices { + loc := fmt.Sprintf("$.devices[%d]", i) + if d.ID == "" { + add("error", "device id is required", loc+".id") + continue + } + if devByID[d.ID] { + add("error", fmt.Sprintf("duplicate device id %q", d.ID), loc+".id") + } + devByID[d.ID] = true + if d.Zone != "" && !zoneByID[d.Zone] { + add("warn", fmt.Sprintf("device %q references unknown zone %q", d.ID, d.Zone), loc+".zone") + } + switch d.Kind { + case "sensor": + if d.Measures == "" { + add("warn", fmt.Sprintf("sensor %q has no 'measures' set", d.ID), loc+".measures") + } + case "actuator": + if d.Controls == "" { + add("warn", fmt.Sprintf("actuator %q has no 'controls' set", d.ID), loc+".controls") + } + case "controller": + // nothing required + case "": + add("error", "device kind is required (sensor|actuator|controller)", loc+".kind") + default: + add("error", fmt.Sprintf("unknown device kind %q", d.Kind), loc+".kind") + } + bus, ok := busByID[d.Bus.Ref] + if !ok { + add("error", fmt.Sprintf("device %q references unknown bus %q", d.ID, d.Bus.Ref), loc+".bus.ref") + continue + } + switch bus.Type { + case "mqtt": + if d.Bus.Topic == "" { + add("error", fmt.Sprintf("device %q on mqtt bus requires bus.topic", d.ID), loc+".bus.topic") + } + case "gpio": + if d.Bus.Line == nil { + add("error", fmt.Sprintf("device %q on gpio bus requires bus.line", d.ID), loc+".bus.line") + } + } + if d.Kind == "actuator" && !d.Bus.Writable { + add("warn", fmt.Sprintf("actuator %q on bus %q is not marked writable", d.ID, d.Bus.Ref), loc+".bus.writable") + } + } + + return diags +} + +var errSpecInvalid = errors.New("spec validation failed") + +// specInitTemplate is the YAML emitted by `fh-agent spec init`. Kept compact +// and heavily commented so a human (or an LLM) can fill it in without +// reading external docs first. +const specInitTemplate = `schemaVersion: 1 + +# Human-readable site name (used as the bundle directory name). +name: my-site + +# Hardware target. Run 'fh-agent targets list' for available ids. +target: rpi5-8gb + +description: "" + +# What the agent is trying to achieve. Each goal becomes part of the +# reasoning prompt. Keep them short and behavioral. +goals: [] +# - id: comfort +# description: Keep occupied rooms comfortable. + +# Hard rules the agent must respect. Free-text in v1 — enforced via prompt, +# not via structural constraints. Tighten in a later version. +constraints: [] +# - id: heating-min +# description: Heating setpoint must never go below 5 °C. + +# Optional zone (room) labels. Devices reference a zone for spatial context. +zones: [] +# - id: livingroom +# name: Wohnzimmer + +# Buses are transports reachable from this site. Each device binds to one. +buses: [] +# - id: home-mqtt +# type: mqtt +# brokerUrl: tcp://localhost:1883 +# - id: gpio +# type: gpio +# - id: serial +# type: serial + +# Physical devices the agent reads or controls. +devices: [] +# - id: temp-livingroom +# zone: livingroom +# kind: sensor +# measures: temperature +# unit: C +# bus: +# ref: home-mqtt +# topic: home/living/temperature +# - id: heating-livingroom +# zone: livingroom +# kind: actuator +# controls: heating +# unit: C +# bus: +# ref: home-mqtt +# topic: home/living/heating/setpoint +# writable: true + +agent: + reasoning: + capability: chat # chat | reasoning | classification | embedding + promptHint: "" + evaluationEvery: 60s +` + +// Hand-written JSON Schema for the spec. Edit alongside the Go types above. +const specSchemaJSON = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "site.spec.yaml", + "type": "object", + "required": ["schemaVersion", "name", "target", "buses", "devices", "agent"], + "additionalProperties": false, + "properties": { + "schemaVersion": {"type": "integer", "const": 1}, + "name": {"type": "string", "minLength": 1}, + "target": {"type": "string", "minLength": 1, "description": "Hardware target id from 'fh-agent targets list'"}, + "description": {"type": "string"}, + "goals": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "description"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "description": {"type": "string", "minLength": 1} + } + } + }, + "constraints": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "description"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "description": {"type": "string", "minLength": 1} + } + } + }, + "zones": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "name": {"type": "string", "minLength": 1} + } + } + }, + "buses": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "type"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "type": {"type": "string", "enum": ["mqtt", "gpio", "serial"]}, + "brokerUrl": {"type": "string"}, + "username": {"type": "string"}, + "password": {"type": "string"}, + "chip": {"type": "string"}, + "device": {"type": "string"}, + "baud": {"type": "integer", "minimum": 0} + } + } + }, + "devices": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "kind", "bus"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "zone": {"type": "string"}, + "kind": {"type": "string", "enum": ["sensor", "actuator", "controller"]}, + "measures": {"type": "string"}, + "controls": {"type": "string"}, + "unit": {"type": "string"}, + "bus": { + "type": "object", + "required": ["ref"], + "additionalProperties": false, + "properties": { + "ref": {"type": "string", "minLength": 1}, + "topic": {"type": "string"}, + "line": {"type": "integer", "minimum": 0}, + "writable": {"type": "boolean"} + } + } + } + } + }, + "agent": { + "type": "object", + "required": ["reasoning"], + "additionalProperties": false, + "properties": { + "reasoning": { + "type": "object", + "required": ["capability"], + "additionalProperties": false, + "properties": { + "capability": {"type": "string", "enum": ["chat", "reasoning", "classification", "embedding"]}, + "promptHint": {"type": "string"} + } + }, + "evaluationEvery": {"type": "string", "pattern": "^[0-9]+(s|m|h)$"} + } + } + } +} +` diff --git a/go/cmd/fh-agent/targets.go b/go/cmd/fh-agent/targets.go new file mode 100644 index 0000000..3940049 --- /dev/null +++ b/go/cmd/fh-agent/targets.go @@ -0,0 +1,211 @@ +package main + +import ( + "bytes" + "embed" + "fmt" + "io/fs" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +//go:embed targets/*.yaml +var targetsFS embed.FS + +// Target is the embedded hardware-profile shape. Each YAML in targets/ +// unmarshals into one of these. +type Target struct { + ID string `yaml:"id" json:"id"` + DisplayName string `yaml:"displayName" json:"displayName"` + Arch string `yaml:"arch" json:"arch"` // arm64 | amd64 + OS string `yaml:"os" json:"os"` + Runtime string `yaml:"runtime" json:"runtime"` // docker | podman | snap + CPU TargetCPU `yaml:"cpu" json:"cpu"` + RAM TargetRAM `yaml:"ram" json:"ram"` + Accel TargetAccel `yaml:"accel" json:"accel"` + Hardware TargetHardware `yaml:"hardware" json:"hardware"` + SLMs []TargetSLM `yaml:"slms" json:"slms"` + SLMRuntime TargetSLMRT `yaml:"slmRuntime" json:"slmRuntime"` + Notes string `yaml:"notes,omitempty" json:"notes,omitempty"` + NotesIO string `yaml:"notes_io,omitempty" json:"notes_io,omitempty"` + IndustrialBuses []string `yaml:"industrialBuses,omitempty" json:"industrialBuses,omitempty"` +} + +type TargetCPU struct { + Model string `yaml:"model" json:"model"` + Cores int `yaml:"cores" json:"cores"` + FreqMHz int `yaml:"freqMHz" json:"freqMHz"` +} + +type TargetRAM struct { + TotalMB int `yaml:"totalMB" json:"totalMB"` + ReservedMB int `yaml:"reservedMB" json:"reservedMB"` +} + +func (r TargetRAM) availableMB() int { + return r.TotalMB - r.ReservedMB +} + +type TargetAccel struct { + Type string `yaml:"type" json:"type"` // none | cuda | npu | igpu + Device string `yaml:"device,omitempty" json:"device,omitempty"` + TOPS float64 `yaml:"tops,omitempty" json:"tops,omitempty"` + Notes string `yaml:"notes,omitempty" json:"notes,omitempty"` +} + +type TargetHardware struct { + GPIOs []TargetGPIO `yaml:"gpios" json:"gpios"` + Serials []TargetSerial `yaml:"serials" json:"serials"` + PWMs []TargetPWM `yaml:"pwms" json:"pwms"` + ADCs []TargetADC `yaml:"adcs" json:"adcs"` + DACs []TargetDAC `yaml:"dacs" json:"dacs"` + I2C []string `yaml:"i2c,omitempty" json:"i2c,omitempty"` + SPI []string `yaml:"spi,omitempty" json:"spi,omitempty"` +} + +type TargetGPIO struct { + ID string `yaml:"id" json:"id"` + Chip string `yaml:"chip" json:"chip"` + Lines int `yaml:"lines" json:"lines"` +} +type TargetSerial struct { + ID string `yaml:"id" json:"id"` + Device string `yaml:"device" json:"device"` + DefaultBaud int `yaml:"defaultBaud" json:"defaultBaud"` +} +type TargetPWM struct { + ID string `yaml:"id" json:"id"` + Chip string `yaml:"chip" json:"chip"` +} +type TargetADC struct { + ID string `yaml:"id" json:"id"` + Device string `yaml:"device" json:"device"` +} +type TargetDAC struct { + ID string `yaml:"id" json:"id"` + Device string `yaml:"device" json:"device"` +} + +type TargetSLM struct { + ID string `yaml:"id" json:"id"` + Capabilities []string `yaml:"capabilities" json:"capabilities"` + RAMMB int `yaml:"ramMB" json:"ramMB"` + EstTokPerSec float64 `yaml:"estTokPerSec,omitempty" json:"estTokPerSec,omitempty"` + Dimension int `yaml:"dimension,omitempty" json:"dimension,omitempty"` + Runtime string `yaml:"runtime,omitempty" json:"runtime,omitempty"` + Notes string `yaml:"notes,omitempty" json:"notes,omitempty"` +} + +type TargetSLMRT struct { + Image string `yaml:"image" json:"image"` + ServePort int `yaml:"servePort" json:"servePort"` + GPURequest string `yaml:"gpuRequest,omitempty" json:"gpuRequest,omitempty"` +} + +// loadAllTargets reads every embedded *.yaml under targets/ exactly once. +// Strict mode (KnownFields) catches typos in profile files at compile-time +// of the binary, not at runtime in a customer environment. +func loadAllTargets() (map[string]*Target, error) { + out := map[string]*Target{} + err := fs.WalkDir(targetsFS, "targets", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + if !strings.HasSuffix(path, ".yaml") { + return nil + } + raw, rerr := targetsFS.ReadFile(path) + if rerr != nil { + return fmt.Errorf("read %s: %w", path, rerr) + } + dec := yaml.NewDecoder(bytes.NewReader(raw)) + dec.KnownFields(true) + var t Target + if derr := dec.Decode(&t); derr != nil { + return fmt.Errorf("parse %s: %w", path, derr) + } + if t.ID == "" { + return fmt.Errorf("%s: missing id", path) + } + if _, dup := out[t.ID]; dup { + return fmt.Errorf("%s: duplicate target id %q", path, t.ID) + } + out[t.ID] = &t + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +// listTargets returns target ids sorted alphabetically for deterministic +// output. +func listTargets() ([]*Target, error) { + all, err := loadAllTargets() + if err != nil { + return nil, err + } + out := make([]*Target, 0, len(all)) + for _, t := range all { + out = append(out, t) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func getTarget(id string) (*Target, error) { + all, err := loadAllTargets() + if err != nil { + return nil, err + } + t, ok := all[id] + if !ok { + ids := make([]string, 0, len(all)) + for k := range all { + ids = append(ids, k) + } + sort.Strings(ids) + return nil, fmt.Errorf("unknown target %q; available: %s", id, strings.Join(ids, ", ")) + } + return t, nil +} + +// suggestModels picks SLMs from the target whose capability set covers the +// requested capability and whose RAM footprint fits the available budget. +// Results are sorted by best-fit: smaller RAM first (leaves headroom), +// then higher estimated throughput. +func suggestModels(t *Target, capability string, maxRAMMB int) []TargetSLM { + avail := t.RAM.availableMB() + if maxRAMMB > 0 && maxRAMMB < avail { + avail = maxRAMMB + } + var out []TargetSLM + for _, m := range t.SLMs { + if !contains(m.Capabilities, capability) { + continue + } + if m.RAMMB > avail { + continue + } + out = append(out, m) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].RAMMB != out[j].RAMMB { + return out[i].RAMMB < out[j].RAMMB + } + return out[i].EstTokPerSec > out[j].EstTokPerSec + }) + return out +} + +func contains(xs []string, x string) bool { + for _, v := range xs { + if v == x { + return true + } + } + return false +} diff --git a/go/cmd/fh-agent/targets/ctrlx-core-arm64.yaml b/go/cmd/fh-agent/targets/ctrlx-core-arm64.yaml new file mode 100644 index 0000000..ae5dfaf --- /dev/null +++ b/go/cmd/fh-agent/targets/ctrlx-core-arm64.yaml @@ -0,0 +1,68 @@ +id: ctrlx-core-arm64 +displayName: Bosch Rexroth ctrlX CORE (ARM64, ctrlX OS) +arch: arm64 +os: linux +runtime: docker +cpu: + model: NXP i.MX 8M Plus (Cortex-A53 quad-core) + cores: 4 + freqMHz: 1600 +ram: + totalMB: 2048 + reservedMB: 512 +accel: + type: npu + device: imx8mp-vpu + tops: 2.3 + notes: | + NXP Neural Processing Unit on i.MX 8M Plus. Used via ONNX Runtime / NNAPI; + not OpenAI-API compatible. Best for classifier/vision side-tasks. +hardware: + gpios: + - id: gpiochip0 + chip: gpiochip0 + lines: 24 + serials: + - id: rs232 + device: /dev/ttymxc0 + defaultBaud: 115200 + - id: rs485 + device: /dev/ttymxc1 + defaultBaud: 115200 + pwms: [] + adcs: [] + dacs: [] + i2c: + - /dev/i2c-1 + spi: + - /dev/spidev0.0 +industrialBuses: + - ethercat + - profinet + - opc-ua + - modbus-tcp +slms: + - id: llama-3.2-1b-instruct-q4 + capabilities: [chat, function_call] + ramMB: 1200 + estTokPerSec: 7 + - id: tinyllama-1.1b-chat-q4 + capabilities: [chat] + ramMB: 720 + estTokPerSec: 9 + - id: distil-bert-cls-q8 + capabilities: [classification] + ramMB: 95 + - id: nomic-embed-text-v1.5-q4 + capabilities: [embedding] + dimension: 768 + ramMB: 220 +slmRuntime: + image: ghcr.io/ggerganov/llama.cpp:server + servePort: 8080 +notes: | + Industrial controller running ctrlX OS (Ubuntu-Core based). Apps deploy as + Snap or Docker (Docker engine available via 'docker-cli' snap). Fieldbus + IO (EtherCAT, PROFINET, OPC-UA, Modbus-TCP) is reached via the ctrlX OS + Data Layer rather than direct GPIO — wire those to MQTT or HTTP channels + in the workflow and let a ctrlX Data-Layer bridge translate. diff --git a/go/cmd/fh-agent/targets/jetson-orin-nano-8gb.yaml b/go/cmd/fh-agent/targets/jetson-orin-nano-8gb.yaml new file mode 100644 index 0000000..f6ff3a4 --- /dev/null +++ b/go/cmd/fh-agent/targets/jetson-orin-nano-8gb.yaml @@ -0,0 +1,67 @@ +id: jetson-orin-nano-8gb +displayName: NVIDIA Jetson Orin Nano (8 GB) +arch: arm64 +os: linux +runtime: docker +cpu: + model: Cortex-A78AE 6-core + cores: 6 + freqMHz: 1500 +ram: + totalMB: 8192 + reservedMB: 1536 +accel: + type: cuda + device: orin + tops: 40 + notes: 1024-core Ampere GPU + 32 Tensor Cores. INT8 sparse 40 TOPS. CUDA, TensorRT, vLLM supported. +hardware: + gpios: + - id: gpiochip0 + chip: gpiochip0 + lines: 40 + serials: + - id: uart-header + device: /dev/ttyTHS1 + defaultBaud: 115200 + pwms: + - id: pwmchip0 + chip: /sys/class/pwm/pwmchip0 + adcs: [] + dacs: [] + i2c: + - /dev/i2c-1 + - /dev/i2c-7 + spi: + - /dev/spidev0.0 +slms: + - id: llama-3.2-3b-instruct-q4 + capabilities: [chat, function_call, reasoning] + ramMB: 2800 + estTokPerSec: 60 + runtime: llama.cpp + - id: llama-3.1-8b-instruct-q4 + capabilities: [chat, function_call, reasoning] + ramMB: 5800 + estTokPerSec: 22 + runtime: llama.cpp + - id: qwen2.5-7b-instruct-awq + capabilities: [chat, function_call, reasoning, code] + ramMB: 6200 + estTokPerSec: 35 + runtime: vllm + - id: nomic-embed-text-v1.5 + capabilities: [embedding] + dimension: 768 + ramMB: 350 + - id: distil-bert-cls + capabilities: [classification] + ramMB: 95 +slmRuntime: + image: dustynv/llama_cpp:r36.4.0 + servePort: 8080 + gpuRequest: all +notes: | + Top edge-AI option for industrial / robotics / vision-language scenarios. + CUDA acceleration enables 7B-class models. Use TensorRT-LLM or vLLM for + production. JetPack 6 / Ubuntu 22.04 base. diff --git a/go/cmd/fh-agent/targets/rpi5-8gb.yaml b/go/cmd/fh-agent/targets/rpi5-8gb.yaml new file mode 100644 index 0000000..b274701 --- /dev/null +++ b/go/cmd/fh-agent/targets/rpi5-8gb.yaml @@ -0,0 +1,64 @@ +id: rpi5-8gb +displayName: Raspberry Pi 5 (8 GB) +arch: arm64 +os: linux +runtime: docker +cpu: + model: BCM2712 (Cortex-A76 quad-core) + cores: 4 + freqMHz: 2400 +ram: + totalMB: 8192 + reservedMB: 1024 +accel: + type: none + notes: VideoCore VII GPU present but not used for LLM inference (no mature CUDA/Metal path). +hardware: + gpios: + - id: gpiochip0 + chip: gpiochip0 + lines: 28 + serials: + - id: uart-onboard + device: /dev/ttyAMA0 + defaultBaud: 115200 + - id: uart-usb + device: /dev/ttyUSB0 + defaultBaud: 115200 + pwms: + - id: pwmchip0 + chip: /sys/class/pwm/pwmchip0 + adcs: [] + dacs: [] + i2c: + - /dev/i2c-1 + spi: + - /dev/spidev0.0 + - /dev/spidev0.1 +slms: + - id: llama-3.2-1b-instruct-q4 + capabilities: [chat, function_call] + ramMB: 1200 + estTokPerSec: 28 + - id: llama-3.2-3b-instruct-q4 + capabilities: [chat, function_call, reasoning] + ramMB: 2800 + estTokPerSec: 11 + - id: qwen2.5-3b-instruct-q5 + capabilities: [chat, function_call, code] + ramMB: 3400 + estTokPerSec: 9 + - id: nomic-embed-text-v1.5-q8 + capabilities: [embedding] + dimension: 768 + ramMB: 280 + - id: distil-bert-cls-q8 + capabilities: [classification] + ramMB: 95 +slmRuntime: + image: ghcr.io/ggerganov/llama.cpp:server + servePort: 8080 +notes: | + General-purpose ARM SBC. Workhorse for home automation and light edge AI. + No GPU/NPU acceleration for LLMs — CPU-only via llama.cpp. + GPIO via libgpiod (gpiochip0). On-board ADC requires HAT. diff --git a/go/cmd/fh-agent/targets/stm32mp25-1gb.yaml b/go/cmd/fh-agent/targets/stm32mp25-1gb.yaml new file mode 100644 index 0000000..32686fa --- /dev/null +++ b/go/cmd/fh-agent/targets/stm32mp25-1gb.yaml @@ -0,0 +1,63 @@ +id: stm32mp25-1gb +displayName: ST STM32MP25 (1 GB, Neural-ART NPU) +arch: arm64 +os: linux +runtime: docker +cpu: + model: Cortex-A35 dual-core + Cortex-M33 (Linux runs on A35) + cores: 2 + freqMHz: 1500 +ram: + totalMB: 1024 + reservedMB: 256 +accel: + type: npu + device: neural-art + tops: 1.35 + notes: | + ST Neural-ART NPU. Operates on quantized models exported via STM32Cube.AI. + Not OpenAI-API compatible — used for classifier/vision nodes, not chat LLMs. +hardware: + gpios: + - id: gpiochip0 + chip: gpiochip0 + lines: 32 + serials: + - id: uart4 + device: /dev/ttySTM0 + defaultBaud: 115200 + pwms: + - id: pwmchip0 + chip: /sys/class/pwm/pwmchip0 + adcs: + - id: adc1 + device: /sys/bus/iio/devices/iio:device0 + dacs: + - id: dac1 + device: /sys/bus/iio/devices/iio:device1 + i2c: + - /dev/i2c-1 + spi: + - /dev/spidev0.0 +slms: + - id: tinyllama-1.1b-chat-q4 + capabilities: [chat] + ramMB: 720 + estTokPerSec: 4 + notes: Tight fit; chat is slow but functional for simple intent extraction. + - id: distil-bert-cls-q8 + capabilities: [classification] + ramMB: 95 + estTokPerSec: 0 + - id: nomic-embed-text-v1.5-q4 + capabilities: [embedding] + dimension: 768 + ramMB: 220 +slmRuntime: + image: ghcr.io/ggerganov/llama.cpp:server + servePort: 8080 +notes: | + Microprocessor-class device (Linux on A35). Native ADC/DAC + on-board NPU. + Strong fit for sensor-fusion + classification edge tasks. + Chat-class LLMs only marginally usable — prefer classification / embedding + workflows here, route reasoning to cloud or to a higher-RAM peer. diff --git a/go/cmd/fh-agent/targets/x86-nuc-16gb.yaml b/go/cmd/fh-agent/targets/x86-nuc-16gb.yaml new file mode 100644 index 0000000..9273d32 --- /dev/null +++ b/go/cmd/fh-agent/targets/x86-nuc-16gb.yaml @@ -0,0 +1,59 @@ +id: x86-nuc-16gb +displayName: Intel NUC / x86 mini-PC (16 GB) +arch: amd64 +os: linux +runtime: docker +cpu: + model: Intel Core i5-1340P class (12-core, P+E) + cores: 12 + freqMHz: 4600 +ram: + totalMB: 16384 + reservedMB: 2048 +accel: + type: igpu + device: intel-iris-xe + notes: Iris Xe iGPU. ipex-llm enables INT4 LLM inference; not NPU-class but capable. +hardware: + gpios: [] + serials: + - id: uart-usb-a + device: /dev/ttyUSB0 + defaultBaud: 115200 + - id: uart-usb-b + device: /dev/ttyUSB1 + defaultBaud: 115200 + pwms: [] + adcs: [] + dacs: [] + i2c: [] + spi: [] +notes_io: | + No on-board GPIO/I2C/SPI/ADC. Industrial IO via USB-CAN, USB-RS485, + Ethernet/Modbus, or USB-GPIO adapters (FT232H, MCP2221). +slms: + - id: llama-3.2-3b-instruct-q4 + capabilities: [chat, function_call, reasoning] + ramMB: 2800 + estTokPerSec: 25 + - id: llama-3.1-8b-instruct-q4 + capabilities: [chat, function_call, reasoning] + ramMB: 5800 + estTokPerSec: 12 + - id: qwen2.5-7b-instruct-q5 + capabilities: [chat, function_call, reasoning, code] + ramMB: 6500 + estTokPerSec: 10 + - id: nomic-embed-text-v1.5 + capabilities: [embedding] + dimension: 768 + ramMB: 350 + - id: bge-reranker-base + capabilities: [classification, reasoning] + ramMB: 600 +slmRuntime: + image: ghcr.io/ggerganov/llama.cpp:server + servePort: 8080 +notes: | + Office-grade gateway. Most RAM-comfortable v1 target. + Best for content-heavy agents (RAG, document QA, vision-language). diff --git a/go/cmd/fh-agent/templates/compose.yml.tmpl b/go/cmd/fh-agent/templates/compose.yml.tmpl new file mode 100644 index 0000000..3a86f44 --- /dev/null +++ b/go/cmd/fh-agent/templates/compose.yml.tmpl @@ -0,0 +1,83 @@ +# Generated by fh-agent. Do not edit by hand — re-run `fh-agent build`. +# +# Stack layout: +# engine — official ForestHub edge-agent engine, configs mounted from ./ +# llm — local SLM server (llama.cpp / Ollama / vLLM image per target) +# mosquitto — optional local MQTT broker; remove if you connect to an +# external broker (then update site.resources.yaml accordingly). + +services: + engine: + image: ghcr.io/foresthubai/edge-agents/engine:latest + platform: linux/{{ .Arch }} + container_name: {{ .Name }}-engine + restart: unless-stopped + environment: + ENGINE_STANDALONE: "true" + ENGINE_ADDR: ":8081" + ENGINE_CONFIG_FILE: /etc/forest/agent.workflow.json + ENGINE_DEPLOYMENT_MAPPING_FILE: /etc/forest/site.mapping.json + ENGINE_EXTERNAL_RESOURCES_FILE: /etc/forest/site.resources.yaml + ENGINE_DEVICE_MANIFEST_FILE: /etc/forest/device.manifest.json + ENGINE_LOG_LEVEL: info + env_file: + - .env + volumes: + - ./agent.workflow.json:/etc/forest/agent.workflow.json:ro + - ./site.mapping.json:/etc/forest/site.mapping.json:ro + - ./site.resources.yaml:/etc/forest/site.resources.yaml:ro + - ./device.manifest.json:/etc/forest/device.manifest.json:ro + - engine-memory:/var/forest/memory +{{- if .NeedsGPIO }} + devices: + - /dev/gpiochip0:/dev/gpiochip0 +{{- end }} +{{- if .NeedsSerial }} + - /dev/ttyAMA0:/dev/ttyAMA0 +{{- end }} + ports: + - "8081:8081" + depends_on: + - llm +{{- if .NeedsMQTT }} + - mosquitto +{{- end }} + + llm: + image: {{ .SLMImage }} + platform: linux/{{ .Arch }} + container_name: {{ .Name }}-llm + restart: unless-stopped + command: ["--host", "0.0.0.0", "--port", "{{ .SLMPort }}", "--model", "/models/{{ .ModelID }}.gguf"] + volumes: + - ./models:/models:ro + ports: + - "{{ .SLMPort }}:{{ .SLMPort }}" +{{- if .GPURequest }} + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: {{ .GPURequest }} + capabilities: [gpu] +{{- end }} + +{{- if .NeedsMQTT }} + + mosquitto: + image: eclipse-mosquitto:2 + container_name: {{ .Name }}-mqtt + restart: unless-stopped + ports: + - "1883:1883" + volumes: + - ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + - mosquitto-data:/mosquitto/data +{{- end }} + +volumes: + engine-memory: +{{- if .NeedsMQTT }} + mosquitto-data: +{{- end }} diff --git a/go/cmd/fh-agent/templates/env.example.tmpl b/go/cmd/fh-agent/templates/env.example.tmpl new file mode 100644 index 0000000..6f8c3e5 --- /dev/null +++ b/go/cmd/fh-agent/templates/env.example.tmpl @@ -0,0 +1,12 @@ +# Generated by fh-agent. Copy to .env and fill in. +# +# Cloud LLM provider keys — only needed if your workflow uses them in +# addition to the local SLM. Leave blank to disable. +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +GEMINI_API_KEY= +MISTRAL_API_KEY= + +# Web-search node provider (optional). +ENGINE_WEB_SEARCH_PROVIDER=brave +ENGINE_WEB_SEARCH_API_KEY= diff --git a/go/cmd/fh-agent/templates/mosquitto.conf.tmpl b/go/cmd/fh-agent/templates/mosquitto.conf.tmpl new file mode 100644 index 0000000..039a5cf --- /dev/null +++ b/go/cmd/fh-agent/templates/mosquitto.conf.tmpl @@ -0,0 +1,4 @@ +listener 1883 +allow_anonymous true +persistence true +persistence_location /mosquitto/data/ diff --git a/go/cmd/fh-agent/templates/readme.md.tmpl b/go/cmd/fh-agent/templates/readme.md.tmpl new file mode 100644 index 0000000..f871ca8 --- /dev/null +++ b/go/cmd/fh-agent/templates/readme.md.tmpl @@ -0,0 +1,60 @@ +# {{ .Name }} + +Edge-AI agent bundle, generated by `fh-agent` for target **{{ .TargetID }}** ({{ .TargetArch }}). + +## What's in this bundle + +| File | Purpose | +| --- | --- | +| `compose.yml` | Docker Compose stack: engine + local LLM (+ optional MQTT broker) | +| `agent.workflow.json` | The workflow graph (binding-free) | +| `site.mapping.json` | Binds workflow channels to platform resources | +| `site.resources.yaml` | MQTT broker URLs and other deploy-time configs | +| `device.manifest.json` | Hardware resources this device exposes | +| `local-models.yaml` | Local SLM registry (capabilities, endpoints) | +| `bundle.meta.json` | Metadata fh-agent uses; safe to inspect | +| `.env.example` | Secrets template — copy to `.env` and fill in | + +## Run it + +1. Copy this whole directory to the target device (`scp -r` is fine). +2. Place model weights under `./models/{{ .ModelID }}.gguf`. + {{- if .ModelDownloadHint }} + Download hint: {{ .ModelDownloadHint }} + {{- end }} +3. Copy `.env.example` to `.env` and fill in any secrets. +4. Bring the stack up: + ```sh + docker compose up -d + ``` +5. Engine HTTP API is on `:8081`. Health check: + ```sh + curl http://localhost:8081/healthz + ``` + +## Architecture chosen + +- **Reasoning model:** `{{ .ModelID }}` ({{ .ModelRAMMB }} MB RAM) +- **SLM runtime image:** `{{ .SLMImage }}` on port {{ .SLMPort }} +- **Accelerator:** {{ .AccelType }}{{ if .AccelDevice }} ({{ .AccelDevice }}){{ end }} + +## Re-generating + +If you change `site.spec.yaml`, re-run the pipeline: + +```sh +fh-agent plan site.spec.yaml --target {{ .TargetID }} --out build/ +fh-agent validate build/ +fh-agent build build/ --name {{ .Name }} --out dist/ +``` + +Every file in this bundle is regenerated from `site.spec.yaml`. Edits made +to the generated files will be overwritten on the next build. + +## Known limits (v1) + +- Workflow JSON is structurally valid per fh-agent's checks but is **not** + validated against the OpenAPI contract here. Run `fh-workflow validate + agent.workflow.json` (from the edge-agents TypeScript CLI) separately to + catch any contract drift. +- Provisioning, OTA updates, and HIL test runs are scoped for later versions. diff --git a/go/cmd/fh-agent/validate.go b/go/cmd/fh-agent/validate.go new file mode 100644 index 0000000..07b1ccd --- /dev/null +++ b/go/cmd/fh-agent/validate.go @@ -0,0 +1,269 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// runValidate cross-checks a build/ directory produced by `fh-agent plan`. +// +// What this DOES check: +// - every workflow channel id has a mapping entry +// - every mapping ref resolves to either an external resource or a manifest entry +// - chosen model exists in local-models.yaml and fits target RAM +// - bundle metadata schema version is current +// - contract-schema validation via `fh-builder validate --json` +// (subprocess; warn-skipped if fh-builder is not installed) +func runValidate(args []string) { + fs := flag.NewFlagSet("validate", flag.ExitOnError) + skipWorkflowCheck := fs.Bool("skip-workflow-check", false, "skip fh-builder contract-schema validation") + _ = fs.Bool("json", true, "emit JSON diagnostics") + pos := parseMixed(fs, args) + if len(pos) < 1 { + die(exitUsage, "usage: fh-agent validate ") + } + dir := pos[0] + + wf, diags := loadWorkflow(filepath.Join(dir, "agent.workflow.json")) + mapping, mdiags := loadMapping(filepath.Join(dir, "site.mapping.json")) + resources, rdiags := loadResources(filepath.Join(dir, "site.resources.yaml")) + manifest, manDiags := loadManifest(filepath.Join(dir, "device.manifest.json")) + meta, metaDiags := loadMetadata(filepath.Join(dir, "bundle.meta.json")) + diags = append(diags, mdiags...) + diags = append(diags, rdiags...) + diags = append(diags, manDiags...) + diags = append(diags, metaDiags...) + + if hasError(diags) { + emitDiags(os.Stderr, diags) + os.Exit(exitDiagnostics) + } + + // 1. Every channel id has a mapping entry. + channelTypes := map[string]string{} + for _, ch := range wf.Channels { + id, _ := ch["id"].(string) + typ, _ := ch["type"].(string) + if id == "" { + continue + } + channelTypes[id] = typ + if _, ok := mapping[id]; !ok { + diags = append(diags, Diagnostic{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("workflow channel %q has no mapping entry", id), + Location: "site.mapping.json", + }) + } + } + + // 2. Every mapping ref resolves. + for chID, b := range mapping { + typ := channelTypes[chID] + switch typ { + case "MQTT": + if _, ok := resources[b.Ref]; !ok { + diags = append(diags, Diagnostic{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("mapping for %q points at resource %q not present in site.resources.yaml", chID, b.Ref), + Location: "site.resources.yaml", + }) + } + case "GPIOIN", "GPIOOUT": + if _, ok := manifest.GPIOs[b.Ref]; !ok { + diags = append(diags, Diagnostic{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("mapping for %q points at gpio %q not present in device.manifest.json", chID, b.Ref), + Location: "device.manifest.json", + }) + } + case "UART": + if _, ok := manifest.Serials[b.Ref]; !ok { + diags = append(diags, Diagnostic{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("mapping for %q points at serial %q not present in device.manifest.json", chID, b.Ref), + Location: "device.manifest.json", + }) + } + } + } + + // 3. Chosen model fits target. + target, err := getTarget(meta.TargetID) + if err != nil { + diags = append(diags, Diagnostic{Severity: "error", Category: "validate", Message: err.Error()}) + } else { + if meta.ChosenModel.RAMMB > target.RAM.availableMB() { + diags = append(diags, Diagnostic{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("chosen model %q needs %d MB RAM, target %q has only %d MB available", + meta.ChosenModel.ID, meta.ChosenModel.RAMMB, target.ID, target.RAM.availableMB()), + }) + } + if !modelOnTarget(target, meta.ChosenModel.ID) { + diags = append(diags, Diagnostic{ + Severity: "warn", Category: "validate", + Message: fmt.Sprintf("chosen model %q is not in target %q's catalog — may not be available locally", + meta.ChosenModel.ID, target.ID), + }) + } + } + + // 4. Bundle metadata schema. + if meta.SchemaVersion != 1 { + diags = append(diags, Diagnostic{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("bundle.meta.json schemaVersion %d; expected 1", meta.SchemaVersion), + }) + } + + // 5. Contract-schema validation via fh-builder. Subprocess; warn-skipped + // if fh-builder is not installed so the tool stays usable offline. + if !*skipWorkflowCheck { + diags = append(diags, runWorkflowSchemaCheck(filepath.Join(dir, "agent.workflow.json"))...) + } + + emitDiags(os.Stderr, diags) + if hasError(diags) { + os.Exit(exitDiagnostics) + } +} + +// runWorkflowSchemaCheck shells out to `fh-builder validate --json` +// (the TS-side contract validator). Returns: +// - the validator's diagnostics on success +// - one warn-diagnostic if fh-builder is not in PATH (so the tool keeps +// working in environments without Node) +// - an error-diagnostic if subprocess failed for an unexpected reason +func runWorkflowSchemaCheck(workflowPath string) []Diagnostic { + bin, err := exec.LookPath("fh-builder") + if err != nil { + return []Diagnostic{{ + Severity: "warn", Category: "validate", + Message: "fh-builder not in PATH — skipping contract-schema validation; install via `npm i -g @foresthubai/app` or pass --skip-workflow-check", + Location: workflowPath, + }} + } + cmd := exec.Command(bin, "validate", workflowPath, "--json") + stdout, err := cmd.Output() + if err != nil { + // Exit 1 means the validator found errors; output is still valid JSON. + if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { + return parseSchemaDiags(stdout, workflowPath) + } + return []Diagnostic{{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("fh-builder validate failed unexpectedly: %v", err), + }} + } + return parseSchemaDiags(stdout, workflowPath) +} + +func parseSchemaDiags(stdout []byte, workflowPath string) []Diagnostic { + if len(stdout) == 0 { + return nil + } + var raw []map[string]any + if err := json.Unmarshal(stdout, &raw); err != nil { + return []Diagnostic{{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("fh-builder JSON parse error: %v", err), + }} + } + out := make([]Diagnostic, 0, len(raw)) + for _, r := range raw { + d := Diagnostic{ + Severity: strOf(r["severity"]), + Category: strOf(r["category"]), + Message: strOf(r["message"]), + Location: workflowPath + "#" + strOf(r["location"]), + NodeID: strOf(r["nodeId"]), + } + out = append(out, d) + } + return out +} + +func strOf(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func modelOnTarget(t *Target, id string) bool { + for _, m := range t.SLMs { + if m.ID == id { + return true + } + } + return false +} + +// ----- minimal loaders for the build/ artifacts ----- + +func loadWorkflow(path string) (Workflow, []Diagnostic) { + raw, err := os.ReadFile(path) + if err != nil { + return Workflow{}, []Diagnostic{{Severity: "error", Category: "validate", Message: "read agent.workflow.json: " + err.Error()}} + } + var wf Workflow + if err := json.Unmarshal(raw, &wf); err != nil { + return Workflow{}, []Diagnostic{{Severity: "error", Category: "validate", Message: "parse agent.workflow.json: " + err.Error()}} + } + return wf, nil +} + +func loadMapping(path string) (DeploymentMapping, []Diagnostic) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, []Diagnostic{{Severity: "error", Category: "validate", Message: "read site.mapping.json: " + err.Error()}} + } + out := DeploymentMapping{} + if err := json.Unmarshal(raw, &out); err != nil { + return nil, []Diagnostic{{Severity: "error", Category: "validate", Message: "parse site.mapping.json: " + err.Error()}} + } + return out, nil +} + +func loadResources(path string) (ExternalResources, []Diagnostic) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, []Diagnostic{{Severity: "error", Category: "validate", Message: "read site.resources.yaml: " + err.Error()}} + } + out := ExternalResources{} + if err := yaml.Unmarshal(raw, &out); err != nil { + return nil, []Diagnostic{{Severity: "error", Category: "validate", Message: "parse site.resources.yaml: " + err.Error()}} + } + return out, nil +} + +func loadManifest(path string) (DeviceManifest, []Diagnostic) { + raw, err := os.ReadFile(path) + if err != nil { + return DeviceManifest{}, []Diagnostic{{Severity: "error", Category: "validate", Message: "read device.manifest.json: " + err.Error()}} + } + var out DeviceManifest + if err := json.Unmarshal(raw, &out); err != nil { + return DeviceManifest{}, []Diagnostic{{Severity: "error", Category: "validate", Message: "parse device.manifest.json: " + err.Error()}} + } + return out, nil +} + +func loadMetadata(path string) (BundleMetadata, []Diagnostic) { + raw, err := os.ReadFile(path) + if err != nil { + return BundleMetadata{}, []Diagnostic{{Severity: "error", Category: "validate", Message: "read bundle.meta.json: " + err.Error()}} + } + var out BundleMetadata + if err := json.Unmarshal(raw, &out); err != nil { + return BundleMetadata{}, []Diagnostic{{Severity: "error", Category: "validate", Message: "parse bundle.meta.json: " + err.Error()}} + } + return out, nil +} diff --git a/go/engine/backend/internal/httpclient/httpclient.go b/go/engine/backend/internal/httpclient/httpclient.go index 37081ea..4a48fff 100644 --- a/go/engine/backend/internal/httpclient/httpclient.go +++ b/go/engine/backend/internal/httpclient/httpclient.go @@ -13,8 +13,19 @@ import ( "net/http" "net/url" "strings" + "time" ) +// defaultTimeout is the per-request ceiling for the underlying http.Client. +// Callers are expected to pass their own context.WithTimeout for tighter +// per-call deadlines (e.g. BootCallbackTimeout=10s, HeartbeatTimeout=5s), +// but this default protects call sites that forget — most notably the deploy +// path (engine.Engine.Deploy → memory.Restore → Snapshot), which inherits an +// uncapped context and would otherwise hang the engine on an unreachable +// backend. 30s is loose enough not to interfere with the tighter per-call +// timeouts and long enough for a slow first-byte on a healthy backend. +const defaultTimeout = 30 * time.Second + // Client is a JSON-over-HTTP client that attaches a fixed auth header to // every request. type Client struct { @@ -31,7 +42,7 @@ func NewClient(baseURL, authHeader, authValue string) *Client { baseURL: strings.TrimRight(baseURL, "/"), authHeader: authHeader, authValue: authValue, - http: &http.Client{}, + http: &http.Client{Timeout: defaultTimeout}, } } diff --git a/go/llmproxy/config/config.go b/go/llmproxy/config/config.go index 5a75696..1ac713e 100644 --- a/go/llmproxy/config/config.go +++ b/go/llmproxy/config/config.go @@ -33,17 +33,6 @@ type EmbeddingConfig struct { ExtractTimeout time.Duration `env:"EMBEDDING_EXTRACT_TIMEOUT" envDefault:"5m"` } -// ResilienceConfig holds the resilience configuration parameters -// type ResilienceConfig struct { -// MaxRetries int `json:"RESILIENCE_MAX_RETRIES" default:"3"` -// InitialDelay time.Duration `json:"RESILIENCE_INITIAL_DELAY" default:"1s"` -// MaxDelay time.Duration `json:"RESILIENCE_MAX_DELAY" default:"30s"` -// Multiplier float64 `json:"RESILIENCE_MULTIPLIER" default:"2.0"` -// Jitter bool `json:"RESILIENCE_JITTER" default:"true"` -// RequestTimeout time.Duration `json:"RESILIENCE_REQUEST_TIMEOUT" default:"30s"` -// ConnectTimeout time.Duration `json:"RESILIENCE_CONNECT_TIMEOUT" default:"10s"` -// } - // NewConfig creates a new configuration instance of the specified type // T is a generic type that allows creating configurations for different structs func NewConfig[T any]() *T { diff --git a/go/llmproxy/resilience.go b/go/llmproxy/resilience.go deleted file mode 100644 index 2197c05..0000000 --- a/go/llmproxy/resilience.go +++ /dev/null @@ -1,23 +0,0 @@ -package llmproxy - -// RetryWithResilienceContext retries the given operation according to the provided resilience configuration. -// func RetryWithResilienceContext(ctx context.Context, cfg config.ResilienceConfig, operation func(ctx context.Context) error) error { -// var err error -// delay := cfg.InitialDelay - -// for i := 0; i < cfg.MaxRetries; i++ { -// err = operation(ctx) -// if err == nil { -// return nil -// } - -// if cfg.Jitter { -// delay = time.Duration(float64(delay) * (1 + rand.Float64()*0.5)) -// } - -// time.Sleep(delay) -// delay = min(time.Duration(float64(delay)*cfg.Multiplier), cfg.MaxDelay) -// } - -// return err -// } diff --git a/ts/app/cli/fh-builder.mjs b/ts/app/cli/fh-builder.mjs old mode 100644 new mode 100755 diff --git a/ts/app/cli/index.ts b/ts/app/cli/index.ts index fbb5022..65c6a4b 100644 --- a/ts/app/cli/index.ts +++ b/ts/app/cli/index.ts @@ -3,23 +3,27 @@ import { updateCommand } from "./update"; import { validateCommand } from "./validate"; const USAGE = `Usage: - fh-builder open [file.json] Open the workflow builder; optionally pre-load a workflow. - fh-builder validate Validate a workflow JSON file. Exits non-zero on errors. - fh-builder update [out] Migrate a workflow to the current schema version. + fh-builder open [file.json] Open the workflow builder; optionally pre-load a workflow. + fh-builder validate [--json] Validate a workflow JSON file. --json emits a machine-readable diagnostics array. Exits non-zero on errors. + fh-builder update [out] Migrate a workflow to the current schema version. `; const [, , command, ...args] = process.argv; +// Strip recognised flags so positional args keep their indexes. +const jsonFlag = args.includes("--json"); +const positional = args.filter((a) => a !== "--json"); + try { switch (command) { case "open": - await openCommand(args[0]); + await openCommand(positional[0]); break; case "validate": - await validateCommand(args[0]); + await validateCommand(positional[0], jsonFlag); break; case "update": - await updateCommand(args[0], args[1]); + await updateCommand(positional[0], positional[1]); break; case undefined: case "--help": diff --git a/ts/app/cli/validate.ts b/ts/app/cli/validate.ts index f4e4232..cba77fe 100644 --- a/ts/app/cli/validate.ts +++ b/ts/app/cli/validate.ts @@ -5,15 +5,23 @@ import type { ValidationResult, Diagnostic } from "@foresthubai/workflow-core/di import type { ApiWorkflow } from "@foresthubai/workflow-core/workflow"; /** - * `fh-builder validate ` + * `fh-builder validate [--json]` * * Reads a workflow snapshot, deserializes it to the in-memory shape, runs * the headless validator, and prints a report. Exits with code 1 if any * errors were found, 0 otherwise. + * + * `--json` emits a flat diagnostics array on stdout for machine consumption + * (used by `fh-agent validate` to merge contract-schema findings into its + * own report). */ -export async function validateCommand(filePath?: string): Promise { +export async function validateCommand(filePath?: string, jsonOutput = false): Promise { if (!filePath) { - process.stderr.write("Usage: fh-builder validate \n"); + if (jsonOutput) { + process.stdout.write(JSON.stringify([{ severity: "error", category: "usage", message: "missing " }])); + } else { + process.stderr.write("Usage: fh-builder validate [--json]\n"); + } process.exit(1); } @@ -24,8 +32,7 @@ export async function validateCommand(filePath?: string): Promise { } catch (err: unknown) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") { - process.stderr.write(`File not found: ${abs}\n`); - process.exit(1); + emitFatal(jsonOutput, `File not found: ${abs}`); } throw err; } @@ -34,25 +41,67 @@ export async function validateCommand(filePath?: string): Promise { try { parsed = JSON.parse(raw); } catch (err) { - process.stderr.write(`Invalid JSON: ${err instanceof Error ? err.message : String(err)}\n`); - process.exit(1); + emitFatal(jsonOutput, `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`); } let workflow: ApiWorkflow; try { workflow = migrate(parsed); } catch (err) { - process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); - process.exit(1); + emitFatal(jsonOutput, err instanceof Error ? err.message : String(err)); } - const result = validateWorkflow(workflow); + const result = validateWorkflow(workflow!); - printReport(abs, result); + if (jsonOutput) { + process.stdout.write(JSON.stringify(flattenDiagnostics(result), null, 2) + "\n"); + } else { + printReport(abs, result); + } if (result.totalErrors > 0) process.exit(1); } +/** + * Flattens the nested ValidationResult into a single array of diagnostics — + * the shape `fh-agent validate` expects, identical to its own diagnostic + * type. Canvas/channel/memory scope is folded into the `location` string. + */ +function flattenDiagnostics(result: ValidationResult): Array> { + const out: Array> = []; + for (const canvas of result.canvases) { + for (const d of canvas.diagnostics) out.push(toFlat(d, `canvas[${canvas.canvasId}]`)); + } + for (const d of result.channelDiagnostics) out.push(toFlat(d, "channels")); + for (const d of result.memoryDiagnostics) out.push(toFlat(d, "memory")); + return out; +} + +function toFlat(d: Diagnostic, scope: string): Record { + const where: string[] = []; + if (d.nodeId) where.push(`node:${d.nodeId}`); + if (d.edgeId) where.push(`edge:${d.edgeId}`); + if (d.channelId) where.push(`channel:${d.channelId}`); + if (d.paramId) where.push(`param:${d.paramId}`); + if (d.outputId) where.push(`output:${d.outputId}`); + return { + severity: d.severity, + category: `workflow:${d.category}`, + message: d.message, + location: where.length > 0 ? `${scope}/${where.join(",")}` : scope, + ...(d.nodeId ? { nodeId: d.nodeId } : {}), + }; +} + +function emitFatal(jsonOutput: boolean, msg: string): never { + if (jsonOutput) { + process.stdout.write(JSON.stringify([{ severity: "error", category: "workflow:io", message: msg }]) + "\n"); + } else { + process.stderr.write(msg + "\n"); + } + process.exit(1); +} + function printReport(file: string, result: ValidationResult): void { const out = process.stdout; diff --git a/ts/workflow-builder/LICENSE b/ts/workflow-builder/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/ts/workflow-builder/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/ts/workflow-builder/NOTICE b/ts/workflow-builder/NOTICE new file mode 100644 index 0000000..7f8f840 --- /dev/null +++ b/ts/workflow-builder/NOTICE @@ -0,0 +1,16 @@ +edge-agents/workflow-builder +Copyright (c) 2026 ForestHub. All rights reserved. + +This product includes software developed by ForestHub +(https://foresthub.ai). + +The @foresthubai/workflow-builder package is the React canvas/editor +component library for the edge-agents platform. This package is +licensed under the GNU Affero General Public License Version 3.0 +(AGPL-3.0-only) or a separate commercial license. + +It depends on @foresthubai/workflow-core which is licensed under the +Apache License, Version 2.0. AGPL allows consumption of Apache-licensed +dependencies; the AGPL terms apply to this package only. + +For commercial licensing inquiries: root@foresthub.ai diff --git a/ts/workflow-builder/package.json b/ts/workflow-builder/package.json index 2202270..4eff90e 100644 --- a/ts/workflow-builder/package.json +++ b/ts/workflow-builder/package.json @@ -2,6 +2,7 @@ "name": "@foresthubai/workflow-builder", "version": "0.1.1", "description": "Reusable React component library: the workflow canvas/editor. Imports workflow-core for types and validation.", + "license": "AGPL-3.0-only", "repository": { "type": "git", "url": "git+https://github.com/ForestHubAI/edge-agents.git", @@ -20,11 +21,15 @@ }, "files": [ "dist", - "src", - "tailwind-preset.ts" + "tailwind-preset.ts", + "src/styles", + "README.md", + "LICENSE", + "NOTICE" ], "publishConfig": { - "registry": "https://npm.pkg.github.com" + "registry": "https://registry.npmjs.org", + "access": "public" }, "scripts": { "build": "tsc -b", diff --git a/ts/workflow-core/LICENSE b/ts/workflow-core/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/ts/workflow-core/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ts/workflow-core/NOTICE b/ts/workflow-core/NOTICE new file mode 100644 index 0000000..b8d6b09 --- /dev/null +++ b/ts/workflow-core/NOTICE @@ -0,0 +1,14 @@ +edge-agents/workflow-core +Copyright (c) 2026 ForestHub + +This product includes software developed by +ForestHub (https://foresthub.ai). + +The @foresthubai/workflow-core package is the headless workflow model and +validation library of the edge-agents platform. This package is licensed +under the Apache License, Version 2.0, while the rest of the repository +(the engine and the React workflow builder) is licensed under AGPL-3.0 +or a separate commercial license. + +See the repository LICENSE for AGPL terms and ts/workflow-core/LICENSE for +the Apache terms applicable to this package. diff --git a/ts/workflow-core/README.md b/ts/workflow-core/README.md new file mode 100644 index 0000000..eb31dfe --- /dev/null +++ b/ts/workflow-core/README.md @@ -0,0 +1,63 @@ +# @foresthubai/workflow-core + +Headless workflow model, serialization, and pure validation for the ForestHub +[edge-agents](https://github.com/ForestHubAI/edge-agents) platform. No React, +no DOM, no browser dependencies — this package is the language-neutral +TypeScript binding for the workflow graph format defined in +[`contract/workflow.yaml`](https://github.com/ForestHubAI/edge-agents/tree/main/contract). + +The Go engine and the React-based workflow builder both consume this contract. +This package gives TypeScript callers the same three-layer model — generated +API types (the wire format), a hand-written domain model, and a pure +`validateWorkflowState` validator — without pulling in any UI or runtime +machinery. + +## Install + +```sh +npm install @foresthubai/workflow-core +``` + +## Quickstart + +```ts +import { deserialize, validateWorkflowState } from "@foresthubai/workflow-core"; + +// `apiWorkflow` is the wire-format JSON produced by the engine or the builder. +const workflow = deserialize(apiWorkflow); +const result = validateWorkflowState(workflow); + +if (!result.ok) { + for (const diagnostic of result.diagnostics) { + console.error(diagnostic.message); + } +} +``` + +Subpath exports (`/api`, `/node`, `/edge`, `/channel`, `/memory`, `/model`, +`/deploy`, `/parameter`, `/function`, `/variable`, `/expression`, `/workflow`, +`/diagnostics`, `/migration`, `/id`) expose the individual domain modules; the +root barrel re-exports the most common types and the validator. + +## Architecture + +The repository [`ts/CLAUDE.md`](https://github.com/ForestHubAI/edge-agents/blob/main/ts/CLAUDE.md) +and [`workflow-core/docs/`](https://github.com/ForestHubAI/edge-agents/tree/main/ts/workflow-core/docs) +have the canonical write-ups on the API/domain/store layering, the parameter +contract, and the `Api`-prefix naming convention. The short version: + +- **API layer** (`src/api/`) — generated from `contract/workflow.yaml`. Never + hand-edit; regenerate with `npm run generate`. +- **Domain layer** (`src/{node,edge,channel,memory,model,function,workflow, + parameter,variable,expression}/`) — hand-written in-memory shape for + validation and import/export. +- **Validation** — `validateWorkflowState` runs on the domain `Workflow`. + +## License + +Apache License 2.0 — see [`LICENSE`](./LICENSE) and [`NOTICE`](./NOTICE). + +The rest of the `edge-agents` repository (the engine and the React workflow +builder) is licensed under AGPL-3.0 or a separate commercial license. This +package is intentionally Apache-2.0 so it can be embedded freely in +downstream tooling and bindings. diff --git a/ts/workflow-core/package.json b/ts/workflow-core/package.json index f62ac76..b21f11b 100644 --- a/ts/workflow-core/package.json +++ b/ts/workflow-core/package.json @@ -2,6 +2,7 @@ "name": "@foresthubai/workflow-core", "version": "0.1.1", "description": "Headless workflow types + serialization + pure validator. No React, no browser.", + "license": "Apache-2.0", "repository": { "type": "git", "url": "git+https://github.com/ForestHubAI/edge-agents.git", @@ -78,10 +79,13 @@ }, "files": [ "dist", - "src" + "README.md", + "LICENSE", + "NOTICE" ], "publishConfig": { - "registry": "https://npm.pkg.github.com" + "registry": "https://registry.npmjs.org", + "access": "public" }, "scripts": { "generate": "openapi-typescript ../../contract/workflow.yaml -o src/api/workflow.ts",