Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
.moltenhub
.moltenhub-agents-*
AGENTS.md
node_modules
prompt-images/
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,24 @@ jobs:
with:
go-version-file: go.mod

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Validate repository
run: ./scripts/validate-repo.sh

- name: Install frontend dependencies
run: npm ci

- name: Run frontend tests
run: npm test

- name: Verify frontend build
run: npm run verify:web-build

- name: Run tests
run: go test ./...

Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
data/
dist/
bin/
node_modules/
coverage.out
AGENTS.md
core.*
.moltenhub-agents-*.md
prompt-images/
.moltenhub
moltenhub
moltenhub
Binary file removed .moltenhub/pr-comment-screenshots/after.png
Binary file not shown.
Binary file removed .moltenhub/pr-comment-screenshots/before.png
Binary file not shown.
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
FROM node:22-alpine AS webbuild

WORKDIR /src

COPY package.json package-lock.json ./
RUN npm ci

COPY vite.config.mjs ./
COPY web ./web
RUN npm run build:web

FROM golang:1.26 AS build

WORKDIR /src
Expand All @@ -6,6 +17,7 @@ COPY go.mod go.sum ./
RUN go mod download

COPY . .
COPY --from=webbuild /src/internal/web/static/skill-payload-form ./internal/web/static/skill-payload-form

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/moltenhub-dispatch ./cmd/moltenhub-dispatch

Expand Down
34 changes: 29 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,23 @@ docker run --rm -p 8080:8080 \

## Docker

Build a local image:
Build a local image from this checkout:

```bash
docker build -t moltenhub-dispatch .
```

Run the local image:
The Docker build runs the web asset build first, then compiles the Go binary with those assets embedded.

Run the local image and complete onboarding in the browser:

```bash
docker run --rm -p 8080:8080 \
-v "$(pwd)/.moltenhub:/workspace/config" \
moltenhub-dispatch
```

Or bind during startup with an existing region and token:

```bash
docker run --rm -p 8080:8080 \
Expand Down Expand Up @@ -68,7 +78,7 @@ The bundled UI supports:

- First-run onboarding for region, bind token, and profile setup.
- Connected-agent management and presence display.
- Manual skill dispatch with Markdown or JSON payloads.
- Manual skill dispatch with schema-generated forms plus Markdown or JSON payload fallback.
- Scheduled and recurring dispatches.
- Pending task status, recent runtime events, and schedule deletion.

Expand Down Expand Up @@ -131,23 +141,37 @@ Dispatch requests can run immediately, later, or on an interval. Use `agent` or
Requirements:

- Go `1.26` or newer
- Node.js `22` or newer, for the bundled JSONForms web assets
- Docker, only for container builds

Run locally:
Install frontend dependencies and build the embedded web bundle:

```bash
npm ci
npm run build:web
```

Run the service locally:

```bash
go run ./cmd/moltenhub-dispatch
```

The web UI runs at <http://localhost:8080>. If you change files under `web/skill-payload-form/`, run `npm run build:web` again before `go run` so the embedded bundle is current.

If a selected skill still shows the raw payload textarea instead of a generated form, confirm that you are running this locally built image or binary, not the published `moltenai/moltenhub-dispatch` image. The form appears only when the selected connected agent advertises a valid JSON schema or supported parameter metadata for that skill; skills without schemas intentionally fall back to the textarea.

Validate changes:

```bash
./scripts/validate-repo.sh
npm test
npm run verify:web-build
go test ./...
go build ./...
```

CI runs the same repository validation, test, and build commands.
CI runs the same repository validation, frontend test/build verification, Go test, and Go build commands.

## Runtime WebSocket

Expand Down
71 changes: 69 additions & 2 deletions internal/app/service_capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,8 @@ func connectedAgentMetadataFromCapabilityEntry(entry map[string]any, sources []m
Harness: firstCapabilityString(sources, "harness"),
Presence: connectedAgentPresenceFromCapabilitySources(sources),
}
if skills := capabilitySkills(entry, nestedMap(entry, "metadata"), nestedMap(entry, "agent")); len(skills) > 0 {
metadata.AdvertisedSkills = SkillsToMetadata(skills)
if skills := capabilitySkillMetadata(entry, nestedMap(entry, "metadata"), nestedMap(entry, "agent")); len(skills) > 0 {
metadata.AdvertisedSkills = skills
}
if metadataEmpty(metadata) {
return nil
Expand Down Expand Up @@ -516,6 +516,73 @@ func SkillsToMetadata(skills []Skill) []map[string]any {
return metadata
}

func capabilitySkillMetadata(primary map[string]any, metadata map[string]any, agent map[string]any) []map[string]any {
for _, source := range []map[string]any{primary, metadata, agent} {
for _, current := range []map[string]any{source, nestedMap(source, "metadata")} {
if current == nil {
continue
}
for _, key := range []string{"advertised_skills", "skills"} {
if skills := skillMetadataFromAny(current[key]); len(skills) > 0 {
return skills
}
}
}
}
return nil
}

func skillMetadataFromAny(value any) []map[string]any {
skills := make([]map[string]any, 0)
appendSkillMap := func(skill map[string]any) {
name := strings.TrimSpace(stringFromMap(skill, "name"))
if name == "" {
return
}
entry := make(map[string]any, len(skill))
for key, value := range skill {
entry[key] = value
}
entry["name"] = name
if description := strings.TrimSpace(stringFromMap(skill, "description")); description != "" {
entry["description"] = description
}
skills = append(skills, entry)
}
appendSkill := func(item any) {
switch typed := item.(type) {
case map[string]any:
appendSkillMap(typed)
case Skill:
if metadata := SkillsToMetadata([]Skill{typed}); len(metadata) == 1 {
skills = append(skills, metadata[0])
}
case string:
name := strings.TrimSpace(typed)
if name != "" {
skills = append(skills, map[string]any{"name": name})
}
}
}

switch typed := value.(type) {
case []any:
for _, item := range typed {
appendSkill(item)
}
case []map[string]any:
for _, item := range typed {
appendSkill(item)
}
case []Skill:
for _, item := range typed {
appendSkill(item)
}
}

return skills
}

func presenceEmpty(presence *hub.AgentPresence) bool {
if presence == nil {
return true
Expand Down
18 changes: 17 additions & 1 deletion internal/app/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4721,7 +4721,16 @@ func TestRefreshConnectedAgentsRetainsPeerCatalogSkillsWhenTalkablePeersExist(t
"uri": "https://hub.example/v1/agents/peer-uuid",
"metadata": map[string]any{
"skills": []map[string]any{
{"name": "review_openapi", "description": "Review Hub API integration behavior."},
{
"name": "review_openapi",
"description": "Review Hub API integration behavior.",
"schema": map[string]any{
"type": "object",
"properties": map[string]any{
"prompt": map[string]any{"type": "string"},
},
},
},
},
},
},
Expand All @@ -4748,6 +4757,13 @@ func TestRefreshConnectedAgentsRetainsPeerCatalogSkillsWhenTalkablePeersExist(t
if skills := ConnectedAgentSkills(agents[0]); len(skills) != 1 || skills[0].Name != "review_openapi" {
t.Fatalf("expected peer skill catalog skills to survive talkable peer fallback, got %#v", skills)
}
if agents[0].Metadata == nil || len(agents[0].Metadata.AdvertisedSkills) != 1 {
t.Fatalf("expected advertised skill metadata with schema, got %#v", agents[0].Metadata)
}
schema, ok := agents[0].Metadata.AdvertisedSkills[0]["schema"].(map[string]any)
if !ok || schema["type"] != "object" {
t.Fatalf("expected advertised skill schema to survive normalization, got %#v", agents[0].Metadata.AdvertisedSkills[0])
}
}

func TestRefreshConnectedAgentsMergesPeerCatalogSkillsWithTalkablePeerProfile(t *testing.T) {
Expand Down
91 changes: 78 additions & 13 deletions internal/web/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1307,35 +1307,56 @@ func TestHandleIndexIncludesSkillSchemaPlaceholderSupport(t *testing.T) {
server.Handler().ServeHTTP(rec, req)

body := rec.Body.String()
if !strings.Contains(body, `/static/skill-payload-form/skill-payload-form.js`) {
t.Fatalf("expected JSONForms skill payload bundle script, body=%s", body)
}
if !strings.Contains(body, `/static/skill-payload-form/skill-payload-form.css`) {
t.Fatalf("expected JSONForms skill payload bundle stylesheet, body=%s", body)
}
if !strings.Contains(body, `id="skill-payload-mode-field"`) {
t.Fatalf("expected payload mode toggle field, body=%s", body)
}
if !strings.Contains(body, `id="skill-payload-mode-form"`) || !strings.Contains(body, `id="skill-payload-mode-json"`) {
t.Fatalf("expected form and JSON payload mode options, body=%s", body)
}
if !strings.Contains(body, `id="skill-payload-form-root"`) {
t.Fatalf("expected JSONForms mount point, body=%s", body)
}
if !strings.Contains(body, `const skillPayloadHint = document.getElementById("skill-payload-hint");`) {
t.Fatalf("expected payload hint hook for schema-aware readonly copy, body=%s", body)
t.Fatalf("expected payload hint hook for schema-aware generated form copy, body=%s", body)
}
if !strings.Contains(body, `const defaultSkillPayloadHint = skillPayloadHint instanceof HTMLElement`) {
t.Fatalf("expected default payload hint capture before schema overrides, body=%s", body)
}
if !strings.Contains(body, `const extractSkillSchema = (skill) => {`) {
t.Fatalf("expected skill schema extraction helper in client script, body=%s", body)
}
if !strings.Contains(body, `for (const key of ["schema", "input_schema", "payload_schema", "inputSchema", "payloadSchema", "parameters", "args_schema", "argsSchema"])`) {
if !strings.Contains(body, `const nestedSchema = firstSkillAliasValue(schema, skillSchemaAliases);`) {
t.Fatalf("expected nested parameter schema extraction in client script, body=%s", body)
}
if !strings.Contains(body, `const skillSchemaAliases = ["schema", "input_schema", "payload_schema", "inputSchema", "payloadSchema", "parameters", "args_schema", "argsSchema"];`) {
t.Fatalf("expected schema alias scan in client script, body=%s", body)
}
if !strings.Contains(body, `const skillUISchemaAliases = ["ui_schema", "uischema", "uiSchema"];`) {
t.Fatalf("expected UI schema alias scan in client script, body=%s", body)
}
if !strings.Contains(body, `schemaText: trimmedString(schemaText),`) {
t.Fatalf("expected skill entries to preserve schema text for readonly payload text, body=%s", body)
t.Fatalf("expected skill entries to preserve schema text for mode reset keys, body=%s", body)
}
if !strings.Contains(body, `Required payload schema shown in readonly payload text box. Markdown and JSON are both supported.`) {
t.Fatalf("expected schema-specific payload hint copy, body=%s", body)
if !strings.Contains(body, `Generated from the selected skill schema. Switch to JSON for raw payload editing.`) {
t.Fatalf("expected generated form payload hint copy, body=%s", body)
}
if !strings.Contains(body, `const setSkillPayloadSchemaText = (schemaText) => {`) {
t.Fatalf("expected payload schema text helper in client script, body=%s", body)
if !strings.Contains(body, `const skillPayloadFormAPI = () => {`) {
t.Fatalf("expected JSONForms bundle bridge helper, body=%s", body)
}
if !strings.Contains(body, `skillPayloadInput.readOnly = nextSchemaText !== "";`) {
t.Fatalf("expected selected skill schema to make payload text readonly, body=%s", body)
if !strings.Contains(body, `api.normalizeSkillSchema(skillEntry.schema, skillEntry.uischema)`) {
t.Fatalf("expected selected skill schema to be normalized through JSONForms bridge, body=%s", body)
}
if !strings.Contains(body, `skillPayloadInput.value = nextSchemaText;`) {
t.Fatalf("expected selected skill schema to render inside payload text box, body=%s", body)
if !strings.Contains(body, `renderSkillPayloadForm(nextData);`) {
t.Fatalf("expected selected skill schema to render generated payload form, body=%s", body)
}
if !strings.Contains(body, `formData.delete("payload");`) || !strings.Contains(body, `formData.delete("payload_format");`) {
t.Fatalf("expected readonly schema reference text to be omitted from dispatch payload, body=%s", body)
if strings.Contains(body, `formData.delete("payload");`) || strings.Contains(body, `formData.delete("payload_format");`) {
t.Fatalf("did not expect generated form payload to be omitted from dispatch, body=%s", body)
}
}

Expand Down Expand Up @@ -2023,6 +2044,47 @@ func TestHandleDispatchAPIAcceptsJSONPayloadWithTabbedPrompt(t *testing.T) {
}
}

func TestHandleDispatchAPIAcceptsGeneratedFormJSONPayload(t *testing.T) {
t.Parallel()

stub := &stubService{
dispatchTask: app.PendingTask{ID: "task-1"},
}
server, err := New(stub)
if err != nil {
t.Fatalf("new server: %v", err)
}

form := url.Values{}
form.Set("target_agent_ref", "worker-a")
form.Set("skill_name", "code_for_me")
form.Set("skill_payload_input_mode", "form")
form.Set("payload_format", "json")
form.Set("payload", `{"prompt":"Review logs","limit":3}`)
req := httptest.NewRequest(http.MethodPost, "/api/dispatch", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()

server.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 response, got %d body=%s", rec.Code, rec.Body.String())
}

if got := stub.lastDispatchReq.PayloadFormat; got != "json" {
t.Fatalf("expected payload format json, got %#v", stub.lastDispatchReq)
}
payload, ok := stub.lastDispatchReq.Payload.(map[string]any)
if !ok {
t.Fatalf("expected JSON payload map, got %T", stub.lastDispatchReq.Payload)
}
if got := payload["prompt"]; got != "Review logs" {
t.Fatalf("unexpected prompt payload: %#v", payload)
}
if got := payload["limit"]; got != float64(3) {
t.Fatalf("unexpected limit payload: %#v", payload)
}
}

func TestHandleDispatchMapsTextPayloadFormatAliasToMarkdown(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -4654,6 +4716,9 @@ func TestHandleIndexRendersConnectedAgentsRefreshPanel(t *testing.T) {
if !strings.Contains(body, `id="initial-connected-agents-data" type="application/json"`) {
t.Fatalf("expected serialized connected-agents bootstrap payload script, body=%s", body)
}
if !strings.Contains(body, `if (typeof parsed === "string")`) {
t.Fatalf("expected initial JSON bootstrap parser to handle double-encoded arrays, body=%s", body)
}
if !strings.Contains(body, `const selectConnectedAgentTarget = (targetRef, options = {}) => {`) {
t.Fatalf("expected connected agent selector click handler, body=%s", body)
}
Expand Down
2 changes: 2 additions & 0 deletions internal/web/static/skill-payload-form/skill-payload-form.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions internal/web/static/skill-payload-form/skill-payload-form.js

Large diffs are not rendered by default.

Loading
Loading