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 {
|
||||
|
||||
@@ -70,3 +70,17 @@ func TestLimitedWriterCaps(t *testing.T) {
|
||||
t.Fatalf("got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerFromJobRun(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"schedule": "schedule",
|
||||
"hook": "hook",
|
||||
"manual": "manual",
|
||||
"cli": "schedule",
|
||||
"": "schedule",
|
||||
} {
|
||||
if got := triggerFromJobRun(in); got != want {
|
||||
t.Fatalf("triggerFromJobRun(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,9 @@ import (
|
||||
const (
|
||||
WebhookEventInboundReceived = "inbound.received"
|
||||
|
||||
InboundModeEvent = "event"
|
||||
InboundModeJob = "job"
|
||||
InboundModeEvent = "event"
|
||||
InboundModeJob = "job"
|
||||
InboundModeFunction = "function"
|
||||
)
|
||||
|
||||
type InboundHookSummary struct {
|
||||
@@ -40,6 +41,7 @@ type InboundHookSummary struct {
|
||||
URL string `json:"url"`
|
||||
Mode string `json:"mode"`
|
||||
JobID string `json:"job_id,omitempty"`
|
||||
FunctionID string `json:"function_id,omitempty"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastReceivedAt *time.Time `json:"last_received_at"`
|
||||
@@ -47,11 +49,12 @@ type InboundHookSummary struct {
|
||||
}
|
||||
|
||||
type CreateInboundHookInput struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Mode string `json:"mode"`
|
||||
JobID string `json:"job_id"`
|
||||
Secret string `json:"secret"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Mode string `json:"mode"`
|
||||
JobID string `json:"job_id"`
|
||||
FunctionID string `json:"function_id"`
|
||||
Secret string `json:"secret"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type InboundReceiveResult struct {
|
||||
@@ -75,6 +78,9 @@ func inboundHookSummary(row db.CoreInboundHook, publicURL string) InboundHookSum
|
||||
if row.JobID.Valid {
|
||||
out.JobID = uuid.UUID(row.JobID.Bytes).String()
|
||||
}
|
||||
if row.FunctionID.Valid {
|
||||
out.FunctionID = uuid.UUID(row.FunctionID.Bytes).String()
|
||||
}
|
||||
if row.LastReceivedAt.Valid {
|
||||
t := row.LastReceivedAt.Time
|
||||
out.LastReceivedAt = &t
|
||||
@@ -108,8 +114,9 @@ func (s *PlatformService) CreateInboundHook(ctx context.Context, actor *models.A
|
||||
if mode == "" {
|
||||
mode = InboundModeEvent
|
||||
}
|
||||
var jobID pgtype.UUID
|
||||
if mode == InboundModeJob {
|
||||
var jobID, functionID pgtype.UUID
|
||||
switch mode {
|
||||
case InboundModeJob:
|
||||
parsed, err := uuid.Parse(input.JobID)
|
||||
if err != nil {
|
||||
return InboundHookSummary{}, errors.New("job mode requires a valid job_id")
|
||||
@@ -119,8 +126,19 @@ func (s *PlatformService) CreateInboundHook(ctx context.Context, actor *models.A
|
||||
return InboundHookSummary{}, errors.New("job not found in this project")
|
||||
}
|
||||
jobID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
} else if mode != InboundModeEvent {
|
||||
return InboundHookSummary{}, fmt.Errorf("invalid mode %q — event or job", input.Mode)
|
||||
case InboundModeFunction:
|
||||
parsed, err := uuid.Parse(input.FunctionID)
|
||||
if err != nil {
|
||||
return InboundHookSummary{}, errors.New("function mode requires a valid function_id")
|
||||
}
|
||||
fn, err := s.repo.Queries().GetFunction(ctx, parsed)
|
||||
if err != nil || fn.ProjectID != projectID {
|
||||
return InboundHookSummary{}, errors.New("function not found in this project")
|
||||
}
|
||||
functionID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
case InboundModeEvent:
|
||||
default:
|
||||
return InboundHookSummary{}, fmt.Errorf("invalid mode %q — event, job, or function", input.Mode)
|
||||
}
|
||||
secretBytes := []byte{}
|
||||
if input.Secret != "" {
|
||||
@@ -141,10 +159,11 @@ func (s *PlatformService) CreateInboundHook(ctx context.Context, actor *models.A
|
||||
ProjectID: projectID,
|
||||
Name: input.Name,
|
||||
Token: token,
|
||||
Mode: mode,
|
||||
JobID: jobID,
|
||||
Secret: secretBytes,
|
||||
Enabled: enabled,
|
||||
Mode: mode,
|
||||
JobID: jobID,
|
||||
FunctionID: functionID,
|
||||
Secret: secretBytes,
|
||||
Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "inbound_hooks_project_id_name_key") {
|
||||
@@ -230,6 +249,31 @@ func (s *PlatformService) ReceiveInboundHook(ctx context.Context, token string,
|
||||
return InboundReceiveResult{}, err
|
||||
}
|
||||
return InboundReceiveResult{Received: true, Event: WebhookEventJobRun, RunID: run.ID.String()}, nil
|
||||
case InboundModeFunction:
|
||||
if s.functions == nil {
|
||||
return InboundReceiveResult{}, errors.New("functions runtime not configured")
|
||||
}
|
||||
if !hook.FunctionID.Valid {
|
||||
return InboundReceiveResult{}, errors.New("hook has no function configured")
|
||||
}
|
||||
fn, err := s.repo.Queries().GetFunction(ctx, uuid.UUID(hook.FunctionID.Bytes))
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err
|
||||
}
|
||||
raw := body
|
||||
if len(raw) == 0 {
|
||||
raw = []byte(`{}`)
|
||||
}
|
||||
// Detached: the public endpoint acks immediately; the run row records
|
||||
// the outcome. Hook callers must not wait on user code.
|
||||
go func() {
|
||||
runCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := s.executeFunction(runCtx, fn, raw, "hook"); err != nil && s.logger != nil {
|
||||
s.logger.Warn("hook function invoke failed", "hook", hook.Name, "function", fn.Name, "error", err)
|
||||
}
|
||||
}()
|
||||
return InboundReceiveResult{Received: true, Event: "function.invoked"}, nil
|
||||
default:
|
||||
s.publishEvent(ctx, hook.ProjectID, WebhookEventInboundReceived, map[string]any{
|
||||
"hook_id": hook.ID.String(),
|
||||
|
||||
@@ -35,6 +35,7 @@ type ScheduledJobSummary struct {
|
||||
Name string `json:"name"`
|
||||
Schedule string `json:"schedule"`
|
||||
URL string `json:"url"`
|
||||
FunctionID *string `json:"function_id,omitempty"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
@@ -59,21 +60,23 @@ type ScheduledJobRunSummary struct {
|
||||
}
|
||||
|
||||
type CreateScheduledJobInput struct {
|
||||
Name string `json:"name" validate:"required,min=2"`
|
||||
Schedule string `json:"schedule" validate:"required"`
|
||||
URL string `json:"url" validate:"required,url"`
|
||||
Secret string `json:"secret"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name string `json:"name" validate:"required,min=2"`
|
||||
Schedule string `json:"schedule" validate:"required"`
|
||||
URL string `json:"url" validate:"required_without=FunctionID,omitempty,url"`
|
||||
FunctionID string `json:"function_id" validate:"omitempty,uuid"`
|
||||
Secret string `json:"secret"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type UpdateScheduledJobInput struct {
|
||||
Name *string `json:"name" validate:"omitempty,min=2"`
|
||||
Schedule *string `json:"schedule"`
|
||||
URL *string `json:"url" validate:"omitempty,url"`
|
||||
Secret *string `json:"secret"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name *string `json:"name" validate:"omitempty,min=2"`
|
||||
Schedule *string `json:"schedule"`
|
||||
URL *string `json:"url" validate:"omitempty,url"`
|
||||
FunctionID *string `json:"function_id" validate:"omitempty,uuid"`
|
||||
Secret *string `json:"secret"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
|
||||
@@ -88,6 +91,10 @@ func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
|
||||
HasSecret: len(row.Secret) > 0,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
}
|
||||
if row.FunctionID.Valid {
|
||||
id := uuid.UUID(row.FunctionID.Bytes).String()
|
||||
out.FunctionID = &id
|
||||
}
|
||||
if row.LastRunAt.Valid {
|
||||
t := row.LastRunAt.Time
|
||||
out.LastRunAt = &t
|
||||
@@ -176,9 +183,25 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return ScheduledJobSummary{}, err
|
||||
}
|
||||
jobURL, err := normalizeWebhookURL(input.URL)
|
||||
if err != nil {
|
||||
return ScheduledJobSummary{}, err
|
||||
jobURL := ""
|
||||
var functionID pgtype.UUID
|
||||
if input.FunctionID != "" {
|
||||
fid, err := uuid.Parse(input.FunctionID)
|
||||
if err != nil {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
|
||||
}
|
||||
fn, err := s.repo.Queries().GetFunction(ctx, fid)
|
||||
if err != nil || fn.ProjectID != projectID {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
|
||||
}
|
||||
functionID = pgtype.UUID{Bytes: fid, Valid: true}
|
||||
jobURL = "function://" + fid.String()
|
||||
} else {
|
||||
var err error
|
||||
jobURL, err = normalizeWebhookURL(input.URL)
|
||||
if err != nil {
|
||||
return ScheduledJobSummary{}, err
|
||||
}
|
||||
}
|
||||
sched, err := parseSchedule(input.Schedule)
|
||||
if err != nil {
|
||||
@@ -222,6 +245,7 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
|
||||
Enabled: enabled,
|
||||
NextRunAt: next,
|
||||
CreatedByUserID: createdBy,
|
||||
FunctionID: functionID,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
|
||||
@@ -260,6 +284,28 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
|
||||
return ScheduledJobSummary{}, err
|
||||
}
|
||||
}
|
||||
rotateFunction := input.FunctionID != nil
|
||||
functionID := row.FunctionID
|
||||
if rotateFunction {
|
||||
raw := strings.TrimSpace(*input.FunctionID)
|
||||
if raw == "" {
|
||||
functionID = pgtype.UUID{} // explicit clear → back to URL delivery
|
||||
if input.URL == nil && strings.HasPrefix(jobURL, "function://") {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("clearing function_id requires a url")
|
||||
}
|
||||
} else {
|
||||
fid, ferr := uuid.Parse(raw)
|
||||
if ferr != nil {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
|
||||
}
|
||||
fn, ferr := s.repo.Queries().GetFunction(ctx, fid)
|
||||
if ferr != nil || fn.ProjectID != projectID {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
|
||||
}
|
||||
functionID = pgtype.UUID{Bytes: fid, Valid: true}
|
||||
jobURL = "function://" + fid.String()
|
||||
}
|
||||
}
|
||||
enabled := row.Enabled
|
||||
if input.Enabled != nil {
|
||||
enabled = *input.Enabled
|
||||
@@ -302,6 +348,8 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
|
||||
Secret: secretBytes,
|
||||
Column10: rotatePayload,
|
||||
Payload: payload,
|
||||
Column12: rotateFunction,
|
||||
FunctionID: functionID,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
|
||||
@@ -410,6 +458,10 @@ type JobScheduler struct {
|
||||
leader *leaderState
|
||||
// onEvent fans a finished run out to webhooks and the realtime stream.
|
||||
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
|
||||
// runFn delivers to a function instead of HTTP when job.FunctionID is set.
|
||||
// The function receives the resolved job payload, not the run envelope.
|
||||
// Owned by PlatformService — the scheduler stays transport-agnostic.
|
||||
runFn func(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error)
|
||||
}
|
||||
|
||||
type jobExec struct {
|
||||
@@ -559,7 +611,8 @@ func (j *JobScheduler) markRan(ctx context.Context, job db.CoreScheduledJob, sta
|
||||
}
|
||||
}
|
||||
|
||||
// deliver POSTs the job payload to its URL, signed like a webhook delivery.
|
||||
// deliver POSTs the job payload to its URL — or invokes the target function
|
||||
// when the job carries a function_id.
|
||||
func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun) (*int32, error) {
|
||||
data, err := resolveSecretRefs(ctx, j.repo.ListProjectSecretValues, j.enc, job.ProjectID, job.Payload)
|
||||
if err != nil {
|
||||
@@ -575,6 +628,12 @@ func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run
|
||||
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"data": data,
|
||||
})
|
||||
if job.FunctionID.Valid {
|
||||
if j.runFn == nil {
|
||||
return nil, fmt.Errorf("functions runtime not configured")
|
||||
}
|
||||
return j.runFn(ctx, job, run, data)
|
||||
}
|
||||
secret := ""
|
||||
if len(job.Secret) > 0 && j.enc != nil {
|
||||
if plain, err := j.enc.Decrypt(job.Secret); err == nil {
|
||||
|
||||
@@ -40,6 +40,7 @@ type PlatformService struct {
|
||||
scheduler *JobScheduler
|
||||
alerts *alertEvaluator
|
||||
functions FunctionRunner
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type BootstrapInput struct {
|
||||
@@ -226,11 +227,12 @@ type InvitationSummary struct {
|
||||
}
|
||||
|
||||
func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger, functions FunctionRunner) *PlatformService {
|
||||
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc, functions: functions}
|
||||
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc, functions: functions, logger: logger}
|
||||
if enc != nil {
|
||||
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
|
||||
s.dispatcher.Start(context.Background())
|
||||
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent, repo.Pool())
|
||||
s.scheduler.runFn = s.runJobFunction
|
||||
s.scheduler.Start(context.Background())
|
||||
s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger, repo.Pool())
|
||||
s.alerts.Start(context.Background())
|
||||
@@ -327,6 +329,7 @@ func (s *PlatformService) publishEvent(ctx context.Context, projectID uuid.UUID,
|
||||
})
|
||||
}
|
||||
s.dispatchEvent(ctx, projectID, eventType, data)
|
||||
s.triggerFunctionsForEvent(ctx, projectID, eventType, data)
|
||||
}
|
||||
|
||||
// SubscribeRealtime registers an SSE consumer for project domain events.
|
||||
|
||||
Reference in new issue
Block a user