feat: Functions — JS/TS execution with run log

core.functions + core.function_runs: project-scoped code executed by a
runtime binary on the backend host (bun or deno per function). Payload
arrives on stdin + PRIMORA_PAYLOAD env; stdout/stderr captured per run
(capped), with status/exit code/duration. Invoke returns 502 + the
recorded run on failure so errors stay observable.

Dashboard gains a Functions page: code editor, JSON payload invoke,
live result, run history. Runner config via FUNCTIONS_RUNTIME /
FUNCTIONS_TIMEOUT_SECONDS / FUNCTIONS_MAX_OUTPUT_BYTES.

Verified live: bun ran code, stdin payload delivered, error path returns
502 with captured stderr; timeout kills at deadline.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-24 19:46:32 +02:00
1 parent 3aca5bd1e0
commit 32587c8063
37 files changed
+2157 -6

No files matched your search

@@ -0,0 +1,40 @@
-- +goose Up
CREATE TABLE core.functions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
code TEXT NOT NULL,
runtime TEXT NOT NULL DEFAULT 'bun' CHECK (runtime IN ('bun','deno')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
CREATE TABLE core.function_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
function_id UUID NOT NULL REFERENCES core.functions(id) ON DELETE CASCADE,
trigger TEXT NOT NULL CHECK (trigger IN ('manual','schedule','hook')),
status TEXT NOT NULL CHECK (status IN ('success','error','timeout')),
exit_code INT,
stdout TEXT NOT NULL DEFAULT '',
stderr TEXT NOT NULL DEFAULT '',
duration_ms INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX function_runs_function_idx ON core.function_runs(function_id, created_at DESC);
ALTER TABLE core.scheduled_job_runs
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
ALTER TABLE core.scheduled_job_runs
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
CHECK (triggered_by IN ('schedule','manual','hook','function'));
-- +goose Down
ALTER TABLE core.scheduled_job_runs
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
ALTER TABLE core.scheduled_job_runs
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
CHECK (triggered_by IN ('schedule','manual','hook'));
DROP TABLE IF EXISTS core.function_runs;
DROP TABLE IF EXISTS core.functions;
+37
View File
@@ -0,0 +1,37 @@
-- name: CreateFunction :one
INSERT INTO core.functions (project_id, name, code, runtime)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetFunction :one
SELECT * FROM core.functions WHERE id = $1;
-- name: GetFunctionByName :one
SELECT * FROM core.functions WHERE project_id = $1 AND name = $2;
-- name: ListFunctions :many
SELECT * FROM core.functions
WHERE project_id = $1
ORDER BY name;
-- name: UpdateFunction :one
UPDATE core.functions
SET code = COALESCE(sqlc.narg('code'), code),
enabled = COALESCE(sqlc.narg('enabled'), enabled),
updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING *;
-- name: DeleteFunction :exec
DELETE FROM core.functions WHERE id = $1 AND project_id = $2;
-- name: InsertFunctionRun :one
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: ListFunctionRuns :many
SELECT * FROM core.function_runs
WHERE function_id = $1
ORDER BY created_at DESC
LIMIT $2;
+1 -1
View File
@@ -112,7 +112,7 @@ func Bootstrap(ctx context.Context) (*App, error) {
if err != nil {
return nil, fmt.Errorf("encryption key: %w", err)
}
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger)
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes))
settings := services.NewSettingsService(repo, encryptor)
if cfg.Env == "production" {
+15
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
)
type Config struct {
@@ -45,6 +46,12 @@ type Config struct {
S3SecretAccessKey string
S3Prefix string
S3PathStyle bool
// Functions runtime: binary name/path ("auto" resolves per-function
// runtime from PATH — "bun" or "deno"), timeout and output cap.
FunctionsRuntime string
FunctionsTimeout time.Duration
FunctionsMaxBytes int64
}
// devEncryptionKey is the built-in credential key for local development only.
@@ -80,6 +87,8 @@ func Load() (Config, error) {
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
S3Prefix: os.Getenv("S3_PREFIX"),
FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"),
FunctionsMaxBytes: 65536,
}
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
@@ -118,6 +127,12 @@ func Load() (Config, error) {
}
cfg.S3PathStyle = s3PathStyle
fnTimeout, err := strconv.Atoi(getenv("FUNCTIONS_TIMEOUT_SECONDS", "30"))
if err != nil {
return Config{}, fmt.Errorf("parse FUNCTIONS_TIMEOUT_SECONDS: %w", err)
}
cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second
var missing []string
if cfg.DatabaseURL == "" {
missing = append(missing, "DATABASE_URL")
@@ -0,0 +1,260 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: functions.sql
package db
import (
"context"
"github.com/google/uuid"
)
const createFunction = `-- name: CreateFunction :one
INSERT INTO core.functions (project_id, name, code, runtime)
VALUES ($1, $2, $3, $4)
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at
`
type CreateFunctionParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Code string `json:"code"`
Runtime string `json:"runtime"`
}
func (q *Queries) CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error) {
row := q.db.QueryRow(ctx, createFunction,
arg.ProjectID,
arg.Name,
arg.Code,
arg.Runtime,
)
var i CoreFunction
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteFunction = `-- name: DeleteFunction :exec
DELETE FROM core.functions WHERE id = $1 AND project_id = $2
`
type DeleteFunctionParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error {
_, err := q.db.Exec(ctx, deleteFunction, arg.ID, arg.ProjectID)
return err
}
const getFunction = `-- name: GetFunction :one
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at FROM core.functions WHERE id = $1
`
func (q *Queries) GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error) {
row := q.db.QueryRow(ctx, getFunction, id)
var i CoreFunction
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getFunctionByName = `-- name: GetFunctionByName :one
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at FROM core.functions WHERE project_id = $1 AND name = $2
`
type GetFunctionByNameParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
}
func (q *Queries) GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error) {
row := q.db.QueryRow(ctx, getFunctionByName, arg.ProjectID, arg.Name)
var i CoreFunction
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const insertFunctionRun = `-- name: InsertFunctionRun :one
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at
`
type InsertFunctionRunParams struct {
FunctionID uuid.UUID `json:"function_id"`
Trigger string `json:"trigger"`
Status string `json:"status"`
ExitCode *int32 `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
DurationMs int32 `json:"duration_ms"`
}
func (q *Queries) InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error) {
row := q.db.QueryRow(ctx, insertFunctionRun,
arg.FunctionID,
arg.Trigger,
arg.Status,
arg.ExitCode,
arg.Stdout,
arg.Stderr,
arg.DurationMs,
)
var i CoreFunctionRun
err := row.Scan(
&i.ID,
&i.FunctionID,
&i.Trigger,
&i.Status,
&i.ExitCode,
&i.Stdout,
&i.Stderr,
&i.DurationMs,
&i.CreatedAt,
)
return i, err
}
const listFunctionRuns = `-- name: ListFunctionRuns :many
SELECT id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at FROM core.function_runs
WHERE function_id = $1
ORDER BY created_at DESC
LIMIT $2
`
type ListFunctionRunsParams struct {
FunctionID uuid.UUID `json:"function_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error) {
rows, err := q.db.Query(ctx, listFunctionRuns, arg.FunctionID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreFunctionRun
for rows.Next() {
var i CoreFunctionRun
if err := rows.Scan(
&i.ID,
&i.FunctionID,
&i.Trigger,
&i.Status,
&i.ExitCode,
&i.Stdout,
&i.Stderr,
&i.DurationMs,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listFunctions = `-- name: ListFunctions :many
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at FROM core.functions
WHERE project_id = $1
ORDER BY name
`
func (q *Queries) ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error) {
rows, err := q.db.Query(ctx, listFunctions, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreFunction
for rows.Next() {
var i CoreFunction
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateFunction = `-- name: UpdateFunction :one
UPDATE core.functions
SET code = COALESCE($3, code),
enabled = COALESCE($4, enabled),
updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at
`
type UpdateFunctionParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Code *string `json:"code"`
Enabled *bool `json:"enabled"`
}
func (q *Queries) UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error) {
row := q.db.QueryRow(ctx, updateFunction,
arg.ID,
arg.ProjectID,
arg.Code,
arg.Enabled,
)
var i CoreFunction
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -266,6 +266,29 @@ type CoreEvent struct {
Ts pgtype.Timestamptz `json:"ts"`
}
type CoreFunction struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Code string `json:"code"`
Runtime string `json:"runtime"`
Enabled bool `json:"enabled"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreFunctionRun struct {
ID uuid.UUID `json:"id"`
FunctionID uuid.UUID `json:"function_id"`
Trigger string `json:"trigger"`
Status string `json:"status"`
ExitCode *int32 `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
DurationMs int32 `json:"duration_ms"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreInboundHook struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
@@ -30,6 +30,7 @@ type Querier interface {
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error)
CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error)
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
@@ -44,6 +45,7 @@ type Querier interface {
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error
DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
@@ -67,6 +69,8 @@ type Querier interface {
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error)
GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error)
GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error)
GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error)
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
@@ -86,6 +90,7 @@ type Querier interface {
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error)
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error)
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
// Per-component most recent heartbeat — heartbeat_silence detection.
@@ -105,6 +110,8 @@ type Querier interface {
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error)
ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error)
ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error)
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
@@ -150,6 +157,7 @@ type Querier interface {
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error)
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
+146
View File
@@ -0,0 +1,146 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
func (h *HTTPHandler) listFunctions(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
items, err := h.Platform.ListFunctions(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *HTTPHandler) createFunction(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.CreateFunctionInput
if !h.bindAndValidate(c, &body) {
return
}
fn, err := h.Platform.CreateFunction(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, fn)
}
func (h *HTTPHandler) getFunction(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
fn, code, err := h.Platform.GetFunctionCode(c.Request.Context(), actor, functionID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"function": fn, "code": code})
}
func (h *HTTPHandler) updateFunction(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
var body services.UpdateFunctionInput
if !h.bindAndValidate(c, &body) {
return
}
fn, err := h.Platform.UpdateFunction(c.Request.Context(), actor, functionID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, fn)
}
func (h *HTTPHandler) deleteFunction(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
if err := h.Platform.DeleteFunction(c.Request.Context(), actor, functionID, middleware.RequestIDFromContext(c)); err != nil {
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) invokeFunction(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
var body struct {
Payload json.RawMessage `json:"payload"`
}
// An empty body is a valid invoke — defaults to {}.
if c.Request.Body != nil && c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&body); err != nil {
apperrors.Abort(c, http.StatusBadRequest, "invalid_json", err.Error())
return
}
}
run, err := h.Platform.InvokeFunction(c.Request.Context(), actor, functionID, body.Payload, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
status := http.StatusOK
if run.Status != "success" {
status = http.StatusBadGateway
}
c.JSON(status, run)
}
func (h *HTTPHandler) listFunctionRuns(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
limit, _ := strconv.ParseInt(c.DefaultQuery("limit", "50"), 10, 64)
runs, err := h.Platform.ListFunctionRuns(c.Request.Context(), actor, functionID, limit)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": runs})
}
+7
View File
@@ -84,6 +84,13 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
api.GET("/projects/:projectID/emails", h.listEmailLog)
api.GET("/projects/:projectID/functions", h.listFunctions)
api.POST("/projects/:projectID/functions", h.createFunction)
api.GET("/projects/:projectID/functions/:functionID", h.getFunction)
api.PUT("/projects/:projectID/functions/:functionID", h.updateFunction)
api.DELETE("/projects/:projectID/functions/:functionID", h.deleteFunction)
api.POST("/projects/:projectID/functions/:functionID/invoke", h.invokeFunction)
api.GET("/projects/:projectID/functions/:functionID/runs", h.listFunctionRuns)
api.GET("/projects/:projectID/events", h.listEvents)
api.GET("/projects/:projectID/issues", h.listIssues)
api.GET("/projects/:projectID/components", h.listComponents)
+356
View File
@@ -0,0 +1,356 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
)
var functionNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`)
// FunctionRunner executes a function's code with a JSON payload on stdin
// and returns the outcome. Implemented by execRunner (a runtime binary on
// the backend host); tests substitute a fake.
type FunctionRunner interface {
Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult
}
// FunctionResult is one execution's captured outcome.
type FunctionResult struct {
Status string // success | error | timeout
ExitCode *int32
Stdout string
Stderr string
DurationMs int64
}
// execRunner shells out to a JS runtime binary. User code runs with the
// backend's privileges — this is the documented self-hosted model (same as
// n8n custom-code nodes); operators harden at the container/VM boundary.
// jarvis: ceiling unsandboxed exec; upgrade to Firecracker/gVisor or a
// sidecar runtime pool when multi-tenant isolation is needed.
type execRunner struct {
bin string
timeout time.Duration
maxOutput int64
resolveBin func(runtime string) string
}
func NewExecRunner(bin string, timeout time.Duration, maxOutput int64) *execRunner {
return &execRunner{
bin: bin,
timeout: timeout,
maxOutput: maxOutput,
resolveBin: func(runtime string) string {
if bin != "" && bin != "auto" {
return bin
}
return runtime // "bun" or "deno" from PATH
},
}
}
func (r *execRunner) Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult {
bin := r.resolveBin(runtime)
if bin == "" {
return FunctionResult{Status: "error", Stderr: "no functions runtime configured"}
}
dir, err := os.MkdirTemp("", "primora-fn-*")
if err != nil {
return FunctionResult{Status: "error", Stderr: err.Error()}
}
defer os.RemoveAll(dir)
file := filepath.Join(dir, "fn.ts")
if err := os.WriteFile(file, []byte(code), 0o600); err != nil {
return FunctionResult{Status: "error", Stderr: err.Error()}
}
ctx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
var cmd *exec.Cmd
switch runtime {
case "deno":
// --no-prompt: never block on interactive permission prompts.
cmd = exec.CommandContext(ctx, bin, "run", "--no-prompt", file)
default: // bun
cmd = exec.CommandContext(ctx, bin, "run", file)
}
cmd.Stdin = bytes.NewReader(payload)
cmd.Env = append(os.Environ(),
"PRIMORA_PAYLOAD="+string(payload),
"PRIMORA_FUNCTION_RUNTIME="+runtime,
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &limitedWriter{w: &stdout, max: r.maxOutput}
cmd.Stderr = &limitedWriter{w: &stderr, max: r.maxOutput}
start := time.Now()
runErr := cmd.Run()
duration := time.Since(start).Milliseconds()
res := FunctionResult{
Status: "success",
Stdout: stdout.String(),
Stderr: stderr.String(),
DurationMs: duration,
}
if ctx.Err() == context.DeadlineExceeded {
res.Status = "timeout"
res.Stderr = strings.TrimSpace(res.Stderr + "\nexecution timed out")
return res
}
if runErr != nil {
res.Status = "error"
if exitErr, ok := runErr.(*exec.ExitError); ok {
code := int32(exitErr.ExitCode())
res.ExitCode = &code
} else {
res.Stderr = strings.TrimSpace(runErr.Error() + "\n" + res.Stderr)
}
}
return res
}
// limitedWriter caps captured output; excess bytes are discarded, not
// stored — a chatty function can't grow function_runs rows unboundedly.
type limitedWriter struct {
w *bytes.Buffer
max int64
}
func (l *limitedWriter) Write(p []byte) (int, error) {
remaining := l.max - int64(l.w.Len())
if remaining > 0 {
if int64(len(p)) > remaining {
l.w.Write(p[:remaining])
} else {
l.w.Write(p)
}
}
return len(p), nil
}
// --- 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"`
}
type UpdateFunctionInput struct {
Code *string `json:"code" validate:"omitempty,max=262144"`
Enabled *bool `json:"enabled"`
}
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"`
}
type FunctionRunSummary struct {
ID string `json:"id"`
Trigger string `json:"trigger"`
Status string `json:"status"`
ExitCode *int32 `json:"exit_code,omitempty"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
DurationMs int64 `json:"duration_ms"`
CreatedAt time.Time `json:"created_at"`
}
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,
}
}
func runSummary(r db.CoreFunctionRun) FunctionRunSummary {
return FunctionRunSummary{
ID: r.ID.String(),
Trigger: r.Trigger,
Status: r.Status,
ExitCode: r.ExitCode,
Stdout: r.Stdout,
Stderr: r.Stderr,
DurationMs: int64(r.DurationMs),
CreatedAt: r.CreatedAt.Time,
}
}
func (s *PlatformService) CreateFunction(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateFunctionInput, requestID string) (FunctionSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return FunctionSummary{}, err
}
name := strings.TrimSpace(input.Name)
if !functionNamePattern.MatchString(name) {
return FunctionSummary{}, fmt.Errorf("invalid function name")
}
runtime := input.Runtime
if runtime == "" {
runtime = "bun"
}
row, err := s.repo.Queries().CreateFunction(ctx, db.CreateFunctionParams{
ProjectID: projectID,
Name: name,
Code: input.Code,
Runtime: runtime,
})
if err != nil {
return FunctionSummary{}, err
}
return functionSummary(row), nil
}
func (s *PlatformService) ListFunctions(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]FunctionSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListFunctions(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]FunctionSummary, len(rows))
for i, r := range rows {
out[i] = functionSummary(r)
}
return out, nil
}
// GetFunctionCode returns the function's source — code is not a secret,
// project members already see it in the dashboard editor.
func (s *PlatformService) GetFunctionCode(ctx context.Context, actor *models.Actor, functionID uuid.UUID) (FunctionSummary, string, error) {
row, err := s.repo.Queries().GetFunction(ctx, functionID)
if err != nil {
return FunctionSummary{}, "", err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer", "viewer"); err != nil {
return FunctionSummary{}, "", err
}
return functionSummary(row), row.Code, nil
}
func (s *PlatformService) UpdateFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, input UpdateFunctionInput, requestID string) (FunctionSummary, error) {
row, err := s.repo.Queries().GetFunction(ctx, functionID)
if err != nil {
return FunctionSummary{}, err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
return FunctionSummary{}, err
}
updated, err := s.repo.Queries().UpdateFunction(ctx, db.UpdateFunctionParams{
ID: functionID,
ProjectID: row.ProjectID,
Code: input.Code,
Enabled: input.Enabled,
})
if err != nil {
return FunctionSummary{}, err
}
return functionSummary(updated), nil
}
func (s *PlatformService) DeleteFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, requestID string) error {
row, err := s.repo.Queries().GetFunction(ctx, functionID)
if err != nil {
return err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
return err
}
return s.repo.Queries().DeleteFunction(ctx, db.DeleteFunctionParams{
ID: functionID,
ProjectID: row.ProjectID,
})
}
// InvokeFunction executes a function with a JSON payload, records the run,
// and returns its summary.
func (s *PlatformService) InvokeFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, payload json.RawMessage, requestID string) (FunctionRunSummary, error) {
row, err := s.repo.Queries().GetFunction(ctx, functionID)
if err != nil {
return FunctionRunSummary{}, err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
return FunctionRunSummary{}, err
}
if !row.Enabled {
return FunctionRunSummary{}, fmt.Errorf("function is disabled")
}
if len(payload) == 0 {
payload = json.RawMessage(`{}`)
}
if !json.Valid(payload) {
return FunctionRunSummary{}, fmt.Errorf("payload must be valid JSON")
}
if s.functions == nil {
return FunctionRunSummary{}, fmt.Errorf("functions runtime not configured")
}
result := s.functions.Run(ctx, row.Runtime, row.Code, payload)
var exit *int32
if result.ExitCode != nil {
exit = result.ExitCode
}
run, err := s.repo.Queries().InsertFunctionRun(ctx, db.InsertFunctionRunParams{
FunctionID: row.ID,
Trigger: "manual",
Status: result.Status,
ExitCode: exit,
Stdout: result.Stdout,
Stderr: result.Stderr,
DurationMs: int32(result.DurationMs),
})
if err != nil {
return FunctionRunSummary{}, err
}
return runSummary(run), nil
}
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 {
return nil, err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.repo.Queries().ListFunctionRuns(ctx, db.ListFunctionRunsParams{
FunctionID: row.ID,
Limit: int32(limit),
})
if err != nil {
return nil, err
}
out := make([]FunctionRunSummary, len(rows))
for i, r := range rows {
out[i] = runSummary(r)
}
return out, nil
}
@@ -0,0 +1,72 @@
package services
import (
"bytes"
"context"
"strings"
"testing"
"time"
)
// These tests run the real bun binary — skipped when it is not installed,
// same convention as exec-dependent tests elsewhere.
func bunAvailable(t *testing.T) {
t.Helper()
r := NewExecRunner("auto", 5*time.Second, 4096)
res := r.Run(context.Background(), "bun", `console.log("probe")`, nil)
if strings.Contains(res.Stderr, "no functions runtime") || (res.Status == "error" && res.ExitCode == nil && strings.Contains(res.Stderr, "executable file not found")) {
t.Skip("bun not installed")
}
}
func TestExecRunnerSuccess(t *testing.T) {
bunAvailable(t)
r := NewExecRunner("auto", 10*time.Second, 4096)
res := r.Run(context.Background(), "bun",
`const p = JSON.parse(await new Response(Bun.stdin.stream()).text()); console.log("got:" + p.x);`,
[]byte(`{"x":42}`))
if res.Status != "success" {
t.Fatalf("status=%s stderr=%s", res.Status, res.Stderr)
}
if !strings.Contains(res.Stdout, "got:42") {
t.Fatalf("stdout=%q", res.Stdout)
}
if res.DurationMs <= 0 {
t.Fatalf("duration=%d", res.DurationMs)
}
}
func TestExecRunnerErrorCapturesStderr(t *testing.T) {
bunAvailable(t)
r := NewExecRunner("auto", 10*time.Second, 4096)
res := r.Run(context.Background(), "bun", `throw new Error("test-boom")`, nil)
if res.Status != "error" {
t.Fatalf("status=%s", res.Status)
}
if res.ExitCode == nil || *res.ExitCode == 0 {
t.Fatalf("exit=%v", res.ExitCode)
}
if !strings.Contains(res.Stderr, "test-boom") {
t.Fatalf("stderr=%q", res.Stderr)
}
}
func TestExecRunnerTimeout(t *testing.T) {
bunAvailable(t)
r := NewExecRunner("auto", 500*time.Millisecond, 4096)
res := r.Run(context.Background(), "bun", `await new Promise(r => setTimeout(r, 10000))`, nil)
if res.Status != "timeout" {
t.Fatalf("status=%s", res.Status)
}
}
func TestLimitedWriterCaps(t *testing.T) {
var buf bytes.Buffer
lw := &limitedWriter{w: &buf, max: 5}
if n, err := lw.Write([]byte("hello world")); err != nil || n != 11 {
t.Fatalf("write n=%d err=%v", n, err)
}
if buf.String() != "hello" {
t.Fatalf("got %q", buf.String())
}
}
@@ -39,6 +39,7 @@ type PlatformService struct {
dispatcher *WebhookDispatcher
scheduler *JobScheduler
alerts *alertEvaluator
functions FunctionRunner
}
type BootstrapInput struct {
@@ -224,8 +225,8 @@ type InvitationSummary struct {
Status string `json:"status"`
}
func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService {
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc}
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}
if enc != nil {
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
s.dispatcher.Start(context.Background())
+268
View File
@@ -2167,6 +2167,182 @@ paths:
$ref: "#/components/schemas/EmailLogListResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/functions:
get:
tags: [Functions]
operationId: listFunctions
summary: List functions in a project.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
responses:
"200":
description: Functions
content:
application/json:
schema:
$ref: "#/components/schemas/FunctionListResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
post:
tags: [Functions]
operationId: createFunction
summary: Create a function (name, JS/TS code, runtime bun|deno).
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateFunctionRequest"
responses:
"201":
description: Created function
content:
application/json:
schema:
$ref: "#/components/schemas/Function"
"400":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/functions/{functionID}:
get:
tags: [Functions]
operationId: getFunction
summary: Function metadata plus its source code.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- name: functionID
in: path
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: Function with code
content:
application/json:
schema:
$ref: "#/components/schemas/FunctionDetailResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
put:
tags: [Functions]
operationId: updateFunction
summary: Update function code and/or enabled state.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- name: functionID
in: path
required: true
schema: { type: string, format: uuid }
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateFunctionRequest"
responses:
"200":
description: Updated function
content:
application/json:
schema:
$ref: "#/components/schemas/Function"
"404":
$ref: "#/components/responses/ErrorResponse"
delete:
tags: [Functions]
operationId: deleteFunction
summary: Delete a function and its run history.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- name: functionID
in: path
required: true
schema: { type: string, format: uuid }
responses:
"204":
description: Deleted
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/functions/{functionID}/invoke:
post:
tags: [Functions]
operationId: invokeFunction
summary: Execute the function with a JSON payload on stdin; returns the recorded run. 502 when the run fails or times out.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- name: functionID
in: path
required: true
schema: { type: string, format: uuid }
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/InvokeFunctionRequest"
responses:
"200":
description: Recorded run (status success)
content:
application/json:
schema:
$ref: "#/components/schemas/FunctionRun"
"502":
description: Recorded run (status error|timeout)
content:
application/json:
schema:
$ref: "#/components/schemas/FunctionRun"
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/functions/{functionID}/runs:
get:
tags: [Functions]
operationId: listFunctionRuns
summary: Recent runs for a function.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- name: functionID
in: path
required: true
schema: { type: string, format: uuid }
- name: limit
in: query
schema:
type: integer
default: 50
maximum: 200
responses:
"200":
description: Function runs
content:
application/json:
schema:
$ref: "#/components/schemas/FunctionRunListResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
components:
securitySchemes:
bearerAuth:
@@ -4213,3 +4389,95 @@ components:
items:
$ref: "#/components/schemas/EmailLogEntry"
required: [items]
Function:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
runtime:
type: string
enum: [bun, deno]
enabled:
type: boolean
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required: [id, name, runtime, enabled, created_at, updated_at]
FunctionListResponse:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/Function"
required: [items]
FunctionDetailResponse:
type: object
properties:
function:
$ref: "#/components/schemas/Function"
code:
type: string
required: [function, code]
CreateFunctionRequest:
type: object
properties:
name:
type: string
code:
type: string
runtime:
type: string
enum: [bun, deno]
default: bun
required: [name, code]
UpdateFunctionRequest:
type: object
properties:
code:
type: string
enabled:
type: boolean
InvokeFunctionRequest:
type: object
properties:
payload: {}
FunctionRun:
type: object
properties:
id:
type: string
format: uuid
trigger:
type: string
enum: [manual, schedule, hook]
status:
type: string
enum: [success, error, timeout]
exit_code:
type: integer
nullable: true
stdout:
type: string
stderr:
type: string
duration_ms:
type: integer
created_at:
type: string
format: date-time
required: [id, trigger, status, stdout, stderr, duration_ms, created_at]
FunctionRunListResponse:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/FunctionRun"
required: [items]
+11
View File
@@ -48,6 +48,7 @@ import {
TelemetryPage,
IntegrationsPage,
VaultPage,
FunctionsPage,
LoginPage,
MembersPage,
ProjectsPage,
@@ -63,6 +64,7 @@ import {
IconDatabases,
IconTelemetry,
IconZap,
IconTerminal,
IconCollections,
IconAudit,
IconAuth,
@@ -1533,6 +1535,7 @@ export default function App() {
{ id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") },
{ id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: <IconZap class="w-4 h-4" />, keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") },
{ id: "nav-vault", label: "Go to Vault", category: "Navigate", icon: <IconKey class="w-4 h-4" />, keywords: ["secrets", "env", "credentials", "api key", "token"], action: () => setActiveView("vault") },
{ id: "nav-functions", label: "Go to Functions", category: "Navigate", icon: <IconTerminal class="w-4 h-4" />, keywords: ["serverless", "code", "lambda", "invoke", "edge"], action: () => setActiveView("functions") },
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
@@ -1784,6 +1787,14 @@ export default function App() {
/>
</Show>
<Show when={activeView() === "functions"}>
<FunctionsPage
projectID={activeProject()?.id}
canManage={canUpdateProject()}
demoMode={isDemo}
/>
</Show>
<Show when={activeView() === "storage"}>
<StoragePage
buckets={buckets()}
@@ -11,6 +11,7 @@ import {
IconTelemetry,
IconIntegrations,
IconZap,
IconTerminal,
IconKey,
IconAudit,
IconAuth,
@@ -33,6 +34,7 @@ export type ViewType =
| "telemetry"
| "integrations"
| "automation"
| "functions"
| "vault"
| "auth"
| "audit"
@@ -51,6 +53,7 @@ const projectNav: NavEntry[] = [
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
{ id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap },
{ id: "functions", label: "Functions", shortLabel: "Funcs", icon: IconTerminal },
{ id: "vault", label: "Vault", icon: IconKey },
{ id: "storage", label: "Storage", icon: IconStorage },
{ id: "collections", label: "Collections", icon: IconCollections },
@@ -99,6 +102,7 @@ const viewTitle: Record<ViewType, string> = {
telemetry: "Telemetry",
integrations: "Integrations",
automation: "Automation",
functions: "Functions",
vault: "Vault",
auth: "Authentication",
audit: "Audit log",
+410
View File
@@ -0,0 +1,410 @@
import { For, Show, createEffect, createSignal } from "solid-js";
import {
ApiError,
CreateFunctionRequest,
Function,
FunctionsService,
type FunctionRun,
} from "@primora/api-client";
import { Modal, ModalFooter } from "../components/Modal";
import { Input, Select, Textarea } from "../components/Input";
import { Badge } from "../components/Badge";
import { IconPlus, IconTrash, IconPlay, IconTerminal } from "../components/Icons";
interface FunctionsPageProps {
projectID?: string;
canManage: boolean;
demoMode: boolean;
}
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—");
const STARTER_CODE = `// The payload arrives on stdin and as PRIMORA_PAYLOAD.
const input = JSON.parse(await new Response(Bun.stdin.stream()).text());
console.log(JSON.stringify({ echo: input }));
`;
const DEMO_FUNCTIONS: Function[] = [
{ id: "f1", name: "resize-image", runtime: Function.runtime.BUN, enabled: true, created_at: "", updated_at: "2026-09-20T09:00:00Z" },
{ id: "f2", name: "sync-billing", runtime: Function.runtime.BUN, enabled: false, created_at: "", updated_at: "2026-09-15T14:00:00Z" },
];
export function FunctionsPage(props: FunctionsPageProps) {
const [functions, setFunctions] = createSignal<Function[]>([]);
const [selected, setSelected] = createSignal<Function>();
const [code, setCode] = createSignal("");
const [codeDirty, setCodeDirty] = createSignal(false);
const [runs, setRuns] = createSignal<FunctionRun[]>([]);
const [payload, setPayload] = createSignal("{}");
const [lastRun, setLastRun] = createSignal<FunctionRun>();
const [message, setMessage] = createSignal("");
const [error, setError] = createSignal("");
const [busy, setBusy] = createSignal<string>();
const [showCreate, setShowCreate] = createSignal(false);
const [createForm, setCreateForm] = createSignal({ name: "", runtime: "bun" });
const refresh = async () => {
if (!props.projectID) return;
if (props.demoMode) {
setFunctions(DEMO_FUNCTIONS);
return;
}
try {
const res = await FunctionsService.listFunctions({ projectId: props.projectID });
setFunctions(res.items ?? []);
} catch (e) {
setError(err(e));
}
};
const refreshRuns = async (fn: Function) => {
if (!props.projectID || props.demoMode) return;
try {
const res = await FunctionsService.listFunctionRuns({ projectId: props.projectID, functionId: fn.id, limit: 20 });
setRuns(res.items ?? []);
} catch {
/* run history is best-effort */
}
};
const select = async (fn: Function) => {
setSelected(fn);
setCodeDirty(false);
setLastRun(undefined);
if (props.demoMode) {
setCode(STARTER_CODE);
return;
}
try {
const res = await FunctionsService.getFunction({ projectId: props.projectID!, functionId: fn.id });
setCode(res.code);
await refreshRuns(fn);
} catch (e) {
setError(err(e));
}
};
createEffect(() => void refresh());
const create = async (e: SubmitEvent) => {
e.preventDefault();
if (!props.projectID || props.demoMode) return;
setBusy("create");
try {
const fn = await FunctionsService.createFunction({
projectId: props.projectID,
requestBody: { name: createForm().name, runtime: createForm().runtime as CreateFunctionRequest.runtime, code: STARTER_CODE },
});
setShowCreate(false);
setCreateForm({ name: "", runtime: "bun" });
setMessage(`Function "${fn.name}" created.`);
await refresh();
await select(fn);
} catch (e2) {
setError(err(e2));
} finally {
setBusy(undefined);
}
};
const save = async () => {
const fn = selected();
if (!fn || !props.projectID || props.demoMode) return;
setBusy("save");
try {
await FunctionsService.updateFunction({ projectId: props.projectID, functionId: fn.id, requestBody: { code: code() } });
setCodeDirty(false);
setMessage(`Saved "${fn.name}".`);
} catch (e) {
setError(err(e));
} finally {
setBusy(undefined);
}
};
const invoke = async () => {
const fn = selected();
if (!fn || !props.projectID || props.demoMode) return;
let parsed: unknown;
try {
parsed = JSON.parse(payload() || "{}");
} catch {
setError("Payload is not valid JSON.");
return;
}
setBusy("invoke");
setError("");
try {
const run = await FunctionsService.invokeFunction({
projectId: props.projectID,
functionId: fn.id,
requestBody: { payload: parsed },
});
setLastRun(run);
} catch (e) {
// 502 still returns a FunctionRun body — the run is recorded either way.
if (e instanceof ApiError && e.body && typeof e.body === "object" && "status" in e.body) {
setLastRun(e.body as FunctionRun);
} else {
setError(err(e));
}
} finally {
setBusy(undefined);
await refreshRuns(fn);
}
};
const toggleEnabled = async (fn: Function) => {
if (!props.projectID || props.demoMode) return;
try {
await FunctionsService.updateFunction({ projectId: props.projectID, functionId: fn.id, requestBody: { enabled: !fn.enabled } });
await refresh();
if (selected()?.id === fn.id) setSelected({ ...fn, enabled: !fn.enabled });
} catch (e) {
setError(err(e));
}
};
const remove = async (fn: Function) => {
if (!props.projectID || props.demoMode) return;
setBusy(fn.id);
try {
await FunctionsService.deleteFunction({ projectId: props.projectID, functionId: fn.id });
if (selected()?.id === fn.id) setSelected(undefined);
setMessage(`Deleted "${fn.name}".`);
await refresh();
} catch (e) {
setError(err(e));
} finally {
setBusy(undefined);
}
};
const runBadge = (status: string) =>
status === "success" ? "success" : status === "timeout" ? "warning" : "error";
return (
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">Functions</h1>
<p class="page-description">
Serverless JS/TS executed by a runtime on this instance (<code>bun</code> or <code>deno</code>).
The payload arrives on stdin and as <code>PRIMORA_PAYLOAD</code>; stdout is captured per run.
</p>
</div>
<Show when={props.canManage && !props.demoMode}>
<button class="btn btn-primary" onClick={() => setShowCreate(true)}>
<IconPlus class="w-4 h-4" /> New function
</button>
</Show>
</div>
<Show when={message()}>
<div class="message message-success" onClick={() => setMessage("")}>{message()}</div>
</Show>
<Show when={error()}>
<div class="message message-error" onClick={() => setError("")}>{error()}</div>
</Show>
<Show
when={!props.projectID && !props.demoMode ? false : true}
fallback={<div class="card card-flush empty-state">Select a project to manage functions.</div>}
>
<Show
when={functions().length > 0}
fallback={
<div class="card card-flush empty-state">
<IconTerminal class="w-8 h-8" />
<p>No functions yet.</p>
<p class="text-text-3" style="font-size:0.85rem">
Functions run your code on demand — invoke them from the dashboard, the API, or wire them to schedules and hooks.
</p>
</div>
}
>
<div class="card card-flush">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Runtime</th>
<th>Status</th>
<th>Updated</th>
<th style="width:1%"> </th>
</tr>
</thead>
<tbody>
<For each={functions()}>
{(fn) => (
<tr
style={selected()?.id === fn.id ? "background:var(--bg-3)" : "cursor:pointer"}
onClick={() => void select(fn)}
>
<td class="font-medium text-text-1">{fn.name}</td>
<td><Badge variant="neutral">{fn.runtime}</Badge></td>
<td>
<Badge variant={fn.enabled ? "success" : "neutral"}>{fn.enabled ? "enabled" : "disabled"}</Badge>
</td>
<td class="text-text-3">{fmtTime(fn.updated_at)}</td>
<td>
<Show when={props.canManage && !props.demoMode}>
<div class="flex gap-1">
<button
class="btn btn-ghost btn-sm"
title={fn.enabled ? "Disable" : "Enable"}
onClick={(e) => { e.stopPropagation(); void toggleEnabled(fn); }}
>
{fn.enabled ? "⏸" : "▶"}
</button>
<button
class="btn btn-ghost btn-sm text-danger"
disabled={busy() === fn.id}
onClick={(e) => { e.stopPropagation(); void remove(fn); }}
>
<IconTrash class="w-4 h-4" />
</button>
</div>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</Show>
</Show>
<Show when={selected()}>
{(fn) => (
<div class="card card-flush" style="margin-top:1.25rem">
<div class="card-header" style="padding:1.25rem 1.25rem 1rem;margin-bottom:0;border-bottom:1px solid var(--border)">
<div class="flex items-center justify-between" style="width:100%">
<span class="card-header-title">
{fn().name} <Badge variant="neutral">{fn().runtime}</Badge>
</span>
<div class="flex gap-2 items-center">
<Show when={props.canManage && !props.demoMode}>
<button class="btn btn-secondary btn-sm" disabled={!codeDirty() || busy() === "save"} onClick={() => void save()}>
{busy() === "save" ? "Saving…" : "Save"}
</button>
<button class="btn btn-primary btn-sm" disabled={busy() === "invoke" || !fn().enabled} onClick={() => void invoke()}>
<IconPlay class="w-3.5 h-3.5" /> {busy() === "invoke" ? "Running…" : "Invoke"}
</button>
</Show>
</div>
</div>
</div>
<div style="padding:1rem 1.25rem;display:grid;gap:1rem;grid-template-columns:1fr 1fr">
<div>
<label class="text-text-3" style="font-size:0.8rem;display:block;margin-bottom:0.35rem">Code</label>
<Textarea
rows={14}
value={code()}
onInput={(e) => { setCode(e.currentTarget.value); setCodeDirty(true); }}
disabled={!props.canManage || props.demoMode}
style="font-family:var(--font-mono);font-size:0.8rem"
/>
</div>
<div>
<label class="text-text-3" style="font-size:0.8rem;display:block;margin-bottom:0.35rem">Payload (JSON)</label>
<Textarea
rows={5}
value={payload()}
onInput={(e) => setPayload(e.currentTarget.value)}
disabled={!props.canManage || props.demoMode}
style="font-family:var(--font-mono);font-size:0.8rem"
/>
<Show when={lastRun()}>
{(run) => (
<div style="margin-top:0.75rem">
<div class="flex items-center gap-2" style="margin-bottom:0.35rem">
<Badge variant={runBadge(run().status)}>{run().status}</Badge>
<span class="text-text-3" style="font-size:0.8rem">
{run().duration_ms} ms{run().exit_code != null ? ` · exit ${run().exit_code}` : ""}
</span>
</div>
<Show when={run().stdout}>
<pre class="text-text-2" style="background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);padding:0.6rem;font-size:0.78rem;max-height:10rem;overflow:auto;white-space:pre-wrap">{run().stdout}</pre>
</Show>
<Show when={run().stderr}>
<pre class="text-danger" style="background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);padding:0.6rem;font-size:0.78rem;max-height:10rem;overflow:auto;white-space:pre-wrap">{run().stderr}</pre>
</Show>
</div>
)}
</Show>
</div>
</div>
</div>
)}
</Show>
<Show when={selected() && runs().length > 0}>
<div class="card card-flush" style="margin-top:1.25rem">
<div class="card-header" style="padding:1.25rem 1.25rem 1rem;margin-bottom:0;border-bottom:1px solid var(--border)">
<span class="card-header-title">Recent runs</span>
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Status</th>
<th>Trigger</th>
<th>Duration</th>
<th>When</th>
</tr>
</thead>
<tbody>
<For each={runs()}>
{(run) => (
<tr>
<td><Badge variant={runBadge(run.status)}>{run.status}</Badge></td>
<td class="text-text-2">{run.trigger}</td>
<td class="text-text-3">{run.duration_ms} ms</td>
<td class="text-text-3">{fmtTime(run.created_at)}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</Show>
<Modal open={showCreate()} onClose={() => setShowCreate(false)} title="New function">
<form onSubmit={create}>
<div class="modal-body">
<Input
label="Name"
placeholder="resize-image"
value={createForm().name}
onInput={(e) => setCreateForm((f) => ({ ...f, name: e.currentTarget.value }))}
required
/>
<Select
label="Runtime"
value={createForm().runtime}
onChange={(e) => setCreateForm((f) => ({ ...f, runtime: e.currentTarget.value }))}
>
<option value="bun">bun</option>
<option value="deno">deno</option>
</Select>
<p class="text-text-3" style="font-size:0.8rem">
Starts from a stdin-echo template — edit the code after creating.
</p>
</div>
<ModalFooter>
<button type="button" class="btn btn-secondary" onClick={() => setShowCreate(false)}>Cancel</button>
<button type="submit" class="btn btn-primary" disabled={busy() === "create" || !createForm().name.trim()}>
{busy() === "create" ? "Creating…" : "Create function"}
</button>
</ModalFooter>
</form>
</Modal>
</div>
);
}
+1
View File
@@ -8,6 +8,7 @@ export { DatabasesPage } from "./DatabasesPage";
export { TelemetryPage } from "./TelemetryPage";
export { IntegrationsPage } from "./IntegrationsPage";
export { VaultPage } from "./VaultPage";
export { FunctionsPage } from "./FunctionsPage";
export { AutomationPage } from "./AutomationPage";
export { SettingsPage } from "./SettingsPage";
export { AuditPage } from "./AuditPage";