Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:

- uses: actions/setup-go@v7
with:
go-version: "1.26.6"
go-version: "1.26.9"
cache: true

- name: Build all packages
Expand All @@ -46,7 +46,7 @@ jobs:

- uses: actions/setup-go@v7
with:
go-version: "1.26.6"
go-version: "1.26.9"
cache: true

- name: Module static tests
Expand Down Expand Up @@ -84,7 +84,7 @@ jobs:

- uses: actions/setup-go@v7
with:
go-version: "1.26.6"
go-version: "1.26.9"
cache: true

- name: Security bypass lint
Expand Down Expand Up @@ -115,7 +115,7 @@ jobs:

- uses: actions/setup-go@v7
with:
go-version: "1.26.6"
go-version: "1.26.9"
cache: true

- name: govulncheck
Expand Down Expand Up @@ -165,7 +165,7 @@ jobs:

- uses: actions/setup-go@v7
with:
go-version: "1.26.6"
go-version: "1.26.9"
cache: true

- name: Regenerate zimports.go
Expand Down Expand Up @@ -206,7 +206,7 @@ jobs:

# - uses: actions/setup-go@v7
# with:
# go-version: "1.26.6"
# go-version: "1.26.9"
# cache: true

# - name: Bootstrap kernel schema
Expand Down
115 changes: 92 additions & 23 deletions addons/automation/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,77 @@ package automation
import (
"context"
"encoding/json"
"fmt"
"strings"

"sumeru/core/applog"
"sumeru/core/event"
"sumeru/core/orm"
)

// executeServerAction runs a matching sys.server.action row for an event.
// Code conventions (declarative, no script engine):
// - publish:<event_name> — publish a follow-up event (payload copied from trigger)
// - write:<json_object> — ORM write on payload model/id
func executeServerAction(ctx context.Context, row map[string]interface{}, ev event.Event) error {
func actionApplies(ctx context.Context, row map[string]interface{}, ev event.Event) bool {
actionModel := strings.TrimSpace(orm.AsString(row["model"]))
if actionModel != "" {
evModel := strings.TrimSpace(orm.AsString(ev.Payload["model"]))
if evModel != "" && actionModel != evModel {
return nil
evModel := strings.TrimSpace(orm.AsString(ev.Payload["model"]))
if ev.Name == "cron.tick" {
if actionModel != "" || strings.TrimSpace(orm.AsString(row["trigger_domain"])) != "" {
return false
}
}
if domRaw := strings.TrimSpace(orm.AsString(row["trigger_domain"])); domRaw != "" {
modelName := strings.TrimSpace(orm.AsString(ev.Payload["model"]))
resID, ok := orm.CoerceInt64(ev.Payload["id"])
if modelName == "" || !ok || resID <= 0 {
return nil
}
var domain [][]interface{}
if err := json.Unmarshal([]byte(domRaw), &domain); err != nil {
return nil
}
matches, err := orm.SearchCount(ctx, modelName, append(domain, []interface{}{"id", "=", resID}))
if err != nil || matches == 0 {
return nil
if actionModel != "" && evModel != "" && actionModel != evModel {
return false
}
domRaw := strings.TrimSpace(orm.AsString(row["trigger_domain"]))
if ev.Name == orm.EventRecordDeleted {
if domRaw == "" {
return true
}
actionID, _ := orm.CoerceInt64(row["id"])
resID, ok := orm.CoerceInt64(ev.Payload["id"])
return ok && deleteActionMatched(evModel, int(resID), actionID)
}
if domRaw == "" {
return true
}
resID, ok := orm.CoerceInt64(ev.Payload["id"])
if evModel == "" || !ok || resID <= 0 {
return false
}
var domain [][]interface{}
if err := json.Unmarshal([]byte(domRaw), &domain); err != nil {
applog.Warn(ctx, applog.Event{
Message: "server action domain JSON invalid",
Component: "automation",
Operation: "server_action",
Status: "failed",
Context: map[string]interface{}{"action": row["name"]},
Err: err,
})
return false
}
matches, err := orm.SearchCount(ctx, evModel, append(domain, []interface{}{"id", "=", resID}))
return err == nil && matches > 0
}

// executeServerAction runs a matching sys.server.action row for an event.
// Code conventions (declarative, no script engine):
// - publish:<event_name> — publish a follow-up event (payload copied from trigger)
// - write:<json_object> — ORM write on payload model/id
// - webhook:<https url>
// - activity:<xmlid>|<summary>
func executeServerAction(ctx context.Context, row map[string]interface{}, ev event.Event) error {
if !actionApplies(ctx, row, ev) {
return nil
}
code := strings.TrimSpace(orm.AsString(row["code"]))
if code == "" {
return nil
}

switch {
case strings.HasPrefix(code, "publish:"):
if depthOf(ctx) >= automationDepthCap {
return fmt.Errorf("automation depth exceeded")
}
name := strings.TrimSpace(strings.TrimPrefix(code, "publish:"))
if name == "" {
return nil
Expand All @@ -53,7 +82,7 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve
if payload == nil {
payload = map[string]interface{}{}
}
errs := event.Publish(ctx, event.Event{Name: name, Actor: ev.Actor, Payload: payload})
errs := event.Publish(withDepth(ctx, depthOf(ctx)+1), event.Event{Name: name, Actor: ev.Actor, Payload: payload})
if len(errs) > 0 {
return errs[0]
}
Expand Down Expand Up @@ -81,6 +110,9 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve
if modelName == "" || !ok || resID <= 0 {
return nil
}
if err := rejectUnsafeWrite(modelName, vals); err != nil {
return err
}
return orm.UpdateRecordByID(ctx, modelName, int(resID), vals)

case strings.HasPrefix(code, "webhook:"):
Expand All @@ -90,10 +122,47 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve
}
return dispatchWebhook(ctx, url, ev)

case strings.HasPrefix(code, "activity:"):
return createActivity(ctx, strings.TrimPrefix(code, "activity:"), ev)

default:
applog.DebugMsg(ctx, "module", "automation",
"server action code not executed (unknown prefix)",
map[string]interface{}{"action": row["name"], "code": code})
}
return nil
}

func createActivity(ctx context.Context, spec string, ev event.Event) error {
spec = strings.TrimSpace(spec)
xmlid, summary, _ := strings.Cut(spec, "|")
xmlid = strings.TrimSpace(xmlid)
summary = strings.TrimSpace(summary)
modelName := strings.TrimSpace(orm.AsString(ev.Payload["model"]))
resID, ok := orm.CoerceInt64(ev.Payload["id"])
if xmlid == "" || modelName == "" || !ok || resID <= 0 {
return fmt.Errorf("activity action requires type, model, and id")
}
if _, registered := orm.Registry["mail.activity"]; !registered {
return fmt.Errorf("mail.activity is not installed")
}
typeID, typeModel, err := orm.ResolveXmlId(ctx, xmlid)
if err != nil || typeModel != "mail.activity.type" || typeID <= 0 {
return fmt.Errorf("unknown activity type %q", xmlid)
}
if summary == "" {
summary = xmlid
}
vals := map[string]interface{}{
"model": modelName,
"res_id": int(resID),
"activity_type_id": typeID,
"summary": summary,
"state": "planned",
}
if ev.Actor > 0 {
vals["user_id"] = ev.Actor
}
_, err = orm.Create(ctx, orm.Registry["mail.activity"], vals)
return err
}
31 changes: 29 additions & 2 deletions addons/automation/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package automation

import (
"context"
"strings"

"sumeru/core/applog"
"sumeru/core/event"
Expand All @@ -15,7 +16,9 @@ func init() {
func registerHooks() {
event.Subscribe("record.created", runServerActionsForEvent)
event.Subscribe("record.updated", runServerActionsForEvent)
event.Subscribe("record.deleted", runServerActionsForEvent)
event.Subscribe("cron.tick", runServerActionsForEvent)
orm.RegisterBeforeDelete(matchBeforeDelete)
}

func runServerActionsForEvent(ctx context.Context, ev event.Event) error {
Expand All @@ -25,6 +28,10 @@ func runServerActionsForEvent(ctx context.Context, ev event.Event) error {
if _, ok := orm.Registry["sys.server.action"]; !ok {
return nil
}
modelName := strings.TrimSpace(orm.AsString(ev.Payload["model"]))
if automationSkippedModel(modelName) {
return nil
}
return orm.WithElevated(ctx, "automation.server_action", func(bypass context.Context) error {
installed, err := orm.InstalledModuleNames(bypass)
if err != nil || !orm.ShouldMaterializeModel("sys.server.action", installed) {
Expand All @@ -37,9 +44,29 @@ func runServerActionsForEvent(ctx context.Context, ev event.Event) error {
if err != nil {
return err
}
runCtx := actorContext(ctx, ev.Actor)
for _, row := range rows {
if err := executeServerAction(ctx, row, ev); err != nil {
applog.Warn(ctx, applog.Event{
if !actionApplies(runCtx, row, ev) {
continue
}
actionID, _ := orm.CoerceInt64(row["id"])
resID, _ := orm.CoerceInt64(ev.Payload["id"])
claimed, err := claimRun(runCtx, actionID, ev, modelName, resID)
if err != nil {
applog.Warn(runCtx, applog.Event{
Message: "server action log failed",
Component: "automation",
Operation: "server_action",
Status: "failed",
Err: err,
})
continue
}
if !claimed {
continue
}
if err := executeServerAction(runCtx, row, ev); err != nil {
applog.Warn(runCtx, applog.Event{
Message: "server action failed",
Component: "automation",
Operation: "server_action",
Expand Down
1 change: 1 addition & 0 deletions addons/automation/init.go

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

2 changes: 1 addition & 1 deletion addons/automation/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"version": "1.0.0",
"author": "Sumeru",
"description": "Scheduled jobs, workflow transitions, server actions, and event bindings.",
"depends": ["base", "audit", "im"],
"depends": ["base", "audit", "im", "mail"],
"application": false,
"data": [
"data/platform_bulk_job_cron.xml",
Expand Down
2 changes: 1 addition & 1 deletion addons/automation/models/sys_server_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ type SysServerAction struct {
EventName sdk.String `sumeru:"index,string=On Event"`
TriggerOn sdk.String `sumeru:"string=Trigger On,default=event"`
TriggerDomain sdk.Text `sumeru:"string=Trigger Domain"`
Code sdk.Text `sumeru:"string=Code / Notes"`
Code sdk.Text `sumeru:"string=Code,help=publish:event write:json webhook:https://host/path activity:module.xmlid|summary"`
Active sdk.Boolean `sumeru:"string=Active,default=true"`
}
15 changes: 15 additions & 0 deletions addons/automation/models/sys_server_action_log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package models

import "sumeru/core/sdk"

type SysServerActionLog struct {
sdk.Model `sumeru:"model=sys.server.action.log"`

ActionID sdk.Many2One[SysServerAction] `sumeru:"required,index,string=Action"`
EventName sdk.String `sumeru:"required,index,string=Event"`
ResModel sdk.String `sumeru:"index,column=model,string=Model"`
ResID sdk.Integer `sumeru:"index,string=Record"`
RanAt sdk.DateTime `sumeru:"required,string=Ran At"`
DedupeKey sdk.String `sumeru:"required,unique,string=Dedupe Key"`
Detail sdk.Text `sumeru:"string=Detail"`
}
1 change: 1 addition & 0 deletions addons/automation/models/zmodels.go

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

Loading
Loading