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/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/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/README.md b/README.md index 1c129c7..91ac59e 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) 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/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/package.json b/ts/workflow-builder/package.json index 2202270..288d252 100644 --- a/ts/workflow-builder/package.json +++ b/ts/workflow-builder/package.json @@ -20,11 +20,14 @@ }, "files": [ "dist", - "src", - "tailwind-preset.ts" + "tailwind-preset.ts", + "src/styles", + "README.md", + "LICENSE" ], "publishConfig": { - "registry": "https://npm.pkg.github.com" + "registry": "https://registry.npmjs.org", + "access": "public" }, "scripts": { "build": "tsc -b", diff --git a/ts/workflow-core/package.json b/ts/workflow-core/package.json index f62ac76..3e5ef5e 100644 --- a/ts/workflow-core/package.json +++ b/ts/workflow-core/package.json @@ -78,10 +78,12 @@ }, "files": [ "dist", - "src" + "README.md", + "LICENSE" ], "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",