mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
feat: Function triggers — schedule, inbound hook, domain events
Migration 00016: function_id on scheduled_jobs/inbound_hooks, event_pattern on functions, 'event' in the run-trigger enum. - Jobs deliver to a function instead of HTTP when function_id is set; the run's status_code mirrors the process exit code. Clearing the field returns the job to URL delivery. - Inbound hooks gain mode "function" — received body becomes stdin payload, acked immediately, run recorded async. - event_pattern (document.*, *, exact type) fires the function detached on matching publishEvent — never blocks realtime/webhook fan-out. - One executeFunction path serves manual/schedule/hook/event; run rows record the trigger source. - Backend image now ships bun 1.3.14 so Functions work under compose. Dashboard: job target picker, hook function mode, event-pattern field. Verified live: job run → success (status_code 0), hook POST → run, document.created → event run with correct payload. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
c094afad65
commit
a84049b8fc
40 files changed
+716
-157
No files matched your search
@@ -148,23 +148,26 @@ func (l *limitedWriter) Write(p []byte) (int, error) {
|
||||
// --- service layer -------------------------------------------------------
|
||||
|
||||
type CreateFunctionInput struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Code string `json:"code" validate:"required,max=262144"`
|
||||
Runtime string `json:"runtime" validate:"omitempty,oneof=bun deno"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Code string `json:"code" validate:"required,max=262144"`
|
||||
Runtime string `json:"runtime" validate:"omitempty,oneof=bun deno"`
|
||||
EventPattern string `json:"event_pattern" validate:"omitempty,max=128"`
|
||||
}
|
||||
|
||||
type UpdateFunctionInput struct {
|
||||
Code *string `json:"code" validate:"omitempty,max=262144"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Code *string `json:"code" validate:"omitempty,max=262144"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
EventPattern *string `json:"event_pattern" validate:"omitempty,max=128"`
|
||||
}
|
||||
|
||||
type FunctionSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Runtime string `json:"runtime"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Runtime string `json:"runtime"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EventPattern string `json:"event_pattern,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type FunctionRunSummary struct {
|
||||
@@ -180,12 +183,13 @@ type FunctionRunSummary struct {
|
||||
|
||||
func functionSummary(r db.CoreFunction) FunctionSummary {
|
||||
return FunctionSummary{
|
||||
ID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
Runtime: r.Runtime,
|
||||
Enabled: r.Enabled,
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
UpdatedAt: r.UpdatedAt.Time,
|
||||
ID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
Runtime: r.Runtime,
|
||||
Enabled: r.Enabled,
|
||||
EventPattern: r.EventPattern,
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
UpdatedAt: r.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,10 +219,11 @@ func (s *PlatformService) CreateFunction(ctx context.Context, actor *models.Acto
|
||||
runtime = "bun"
|
||||
}
|
||||
row, err := s.repo.Queries().CreateFunction(ctx, db.CreateFunctionParams{
|
||||
ProjectID: projectID,
|
||||
Name: name,
|
||||
Code: input.Code,
|
||||
Runtime: runtime,
|
||||
ProjectID: projectID,
|
||||
Name: name,
|
||||
Code: input.Code,
|
||||
Runtime: runtime,
|
||||
EventPattern: strings.TrimSpace(input.EventPattern),
|
||||
})
|
||||
if err != nil {
|
||||
return FunctionSummary{}, err
|
||||
@@ -262,11 +267,17 @@ func (s *PlatformService) UpdateFunction(ctx context.Context, actor *models.Acto
|
||||
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
|
||||
return FunctionSummary{}, err
|
||||
}
|
||||
var pattern *string
|
||||
if input.EventPattern != nil {
|
||||
p := strings.TrimSpace(*input.EventPattern)
|
||||
pattern = &p
|
||||
}
|
||||
updated, err := s.repo.Queries().UpdateFunction(ctx, db.UpdateFunctionParams{
|
||||
ID: functionID,
|
||||
ProjectID: row.ProjectID,
|
||||
Code: input.Code,
|
||||
Enabled: input.Enabled,
|
||||
ID: functionID,
|
||||
ProjectID: row.ProjectID,
|
||||
Code: input.Code,
|
||||
Enabled: input.Enabled,
|
||||
EventPattern: pattern,
|
||||
})
|
||||
if err != nil {
|
||||
return FunctionSummary{}, err
|
||||
@@ -298,6 +309,13 @@ func (s *PlatformService) InvokeFunction(ctx context.Context, actor *models.Acto
|
||||
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
|
||||
return FunctionRunSummary{}, err
|
||||
}
|
||||
return s.executeFunction(ctx, row, payload, "manual")
|
||||
}
|
||||
|
||||
// executeFunction runs a function row and records the outcome — shared by
|
||||
// manual invoke, schedule/hook delivery, and event triggers (no role check;
|
||||
// callers hold their own auth context or are system paths).
|
||||
func (s *PlatformService) executeFunction(ctx context.Context, row db.CoreFunction, payload json.RawMessage, trigger string) (FunctionRunSummary, error) {
|
||||
if !row.Enabled {
|
||||
return FunctionRunSummary{}, fmt.Errorf("function is disabled")
|
||||
}
|
||||
@@ -317,7 +335,7 @@ func (s *PlatformService) InvokeFunction(ctx context.Context, actor *models.Acto
|
||||
}
|
||||
run, err := s.repo.Queries().InsertFunctionRun(ctx, db.InsertFunctionRunParams{
|
||||
FunctionID: row.ID,
|
||||
Trigger: "manual",
|
||||
Trigger: trigger,
|
||||
Status: result.Status,
|
||||
ExitCode: exit,
|
||||
Stdout: result.Stdout,
|
||||
@@ -330,6 +348,79 @@ func (s *PlatformService) InvokeFunction(ctx context.Context, actor *models.Acto
|
||||
return runSummary(run), nil
|
||||
}
|
||||
|
||||
// runJobFunction is the scheduler's delivery path for function-targeted
|
||||
// jobs: load the function, run it with the resolved payload, record the
|
||||
// run. The job run's status_code mirrors the process exit code.
|
||||
func (s *PlatformService) runJobFunction(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error) {
|
||||
fn, err := s.repo.Queries().GetFunction(ctx, uuid.UUID(job.FunctionID.Bytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("target function missing: %w", err)
|
||||
}
|
||||
summary, err := s.executeFunction(ctx, fn, payload, triggerFromJobRun(run.TriggeredBy))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code := int32(0)
|
||||
if summary.ExitCode != nil {
|
||||
code = *summary.ExitCode
|
||||
}
|
||||
if summary.Status != "success" {
|
||||
return &code, fmt.Errorf("function %q %s: %s", fn.Name, summary.Status, truncate(summary.Stderr, 200))
|
||||
}
|
||||
return &code, nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
func triggerFromJobRun(triggeredBy string) string {
|
||||
switch triggeredBy {
|
||||
case "schedule", "hook", "manual":
|
||||
return triggeredBy
|
||||
default:
|
||||
return "schedule"
|
||||
}
|
||||
}
|
||||
|
||||
// triggerFunctionsForEvent fans a domain event out to every enabled
|
||||
// function whose event_pattern matches ("document.*", "*", …). Runs are
|
||||
// detached — event publishers never block on user code.
|
||||
// jarvis: ceiling pattern is a single * suffix/prefix wildcard via SQL
|
||||
// LIKE; upgrade to a real matcher if multi-segment globs are needed.
|
||||
func (s *PlatformService) triggerFunctionsForEvent(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
|
||||
if s.functions == nil {
|
||||
return
|
||||
}
|
||||
rows, err := s.repo.Queries().ListFunctionsForEvent(ctx, db.ListFunctionsForEventParams{
|
||||
ProjectID: projectID,
|
||||
EventPattern: eventType,
|
||||
})
|
||||
if err != nil || len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event": eventType,
|
||||
"project_id": projectID.String(),
|
||||
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"data": data,
|
||||
})
|
||||
for _, fn := range rows {
|
||||
fn := fn
|
||||
go func() {
|
||||
// Detached context: the publishing request may finish first.
|
||||
runCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := s.executeFunction(runCtx, fn, payload, "event"); err != nil {
|
||||
s.logger.Warn("function event trigger failed", "function", fn.Name, "event", eventType, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListFunctionRuns(ctx context.Context, actor *models.Actor, functionID uuid.UUID, limit int64) ([]FunctionRunSummary, error) {
|
||||
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user