From 32587c806348af95101a00d8b1b8ccabbc48907d Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 24 Sep 2026 19:46:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Functions=20=E2=80=94=20JS/TS=20executi?= =?UTF-8?q?on=20with=20run=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- CHANGELOG.md | 1 + ROADMAP.md | 8 +- .../backend/db/migrations/00015_functions.sql | 40 ++ apps/backend/db/queries/functions.sql | 37 ++ apps/backend/internal/app/app.go | 2 +- apps/backend/internal/config/config.go | 15 + .../internal/database/db/functions.sql.go | 260 +++++++++++ apps/backend/internal/database/db/models.go | 23 + apps/backend/internal/database/db/querier.go | 8 + apps/backend/internal/handlers/functions.go | 146 +++++++ apps/backend/internal/handlers/http.go | 7 + apps/backend/internal/services/functions.go | 356 +++++++++++++++ .../internal/services/functions_test.go | 72 +++ .../internal/services/platform_service.go | 5 +- apps/backend/openapi/openapi.yaml | 268 ++++++++++++ apps/frontend/src/App.tsx | 11 + apps/frontend/src/components/AppShell.tsx | 4 + apps/frontend/src/pages/FunctionsPage.tsx | 410 ++++++++++++++++++ apps/frontend/src/pages/index.ts | 1 + packages/api-client/src/generated/index.ts | 17 + .../generated/models/CreateFunctionRequest.ts | 16 + .../src/generated/models/Function.ts | 19 + .../models/FunctionDetailResponse.ts | 10 + .../generated/models/FunctionListResponse.ts | 9 + .../src/generated/models/FunctionRun.ts | 27 ++ .../models/FunctionRunListResponse.ts | 9 + .../generated/models/InvokeFunctionRequest.ts | 8 + .../generated/models/UpdateFunctionRequest.ts | 9 + .../schemas/$CreateFunctionRequest.ts | 19 + .../src/generated/schemas/$Function.ts | 35 ++ .../schemas/$FunctionDetailResponse.ts | 16 + .../schemas/$FunctionListResponse.ts | 15 + .../src/generated/schemas/$FunctionRun.ts | 42 ++ .../schemas/$FunctionRunListResponse.ts | 15 + .../schemas/$InvokeFunctionRequest.ts | 12 + .../schemas/$UpdateFunctionRequest.ts | 14 + .../generated/services/FunctionsService.ts | 197 +++++++++ 37 files changed, 2157 insertions(+), 6 deletions(-) create mode 100644 apps/backend/db/migrations/00015_functions.sql create mode 100644 apps/backend/db/queries/functions.sql create mode 100644 apps/backend/internal/database/db/functions.sql.go create mode 100644 apps/backend/internal/handlers/functions.go create mode 100644 apps/backend/internal/services/functions.go create mode 100644 apps/backend/internal/services/functions_test.go create mode 100644 apps/frontend/src/pages/FunctionsPage.tsx create mode 100644 packages/api-client/src/generated/models/CreateFunctionRequest.ts create mode 100644 packages/api-client/src/generated/models/Function.ts create mode 100644 packages/api-client/src/generated/models/FunctionDetailResponse.ts create mode 100644 packages/api-client/src/generated/models/FunctionListResponse.ts create mode 100644 packages/api-client/src/generated/models/FunctionRun.ts create mode 100644 packages/api-client/src/generated/models/FunctionRunListResponse.ts create mode 100644 packages/api-client/src/generated/models/InvokeFunctionRequest.ts create mode 100644 packages/api-client/src/generated/models/UpdateFunctionRequest.ts create mode 100644 packages/api-client/src/generated/schemas/$CreateFunctionRequest.ts create mode 100644 packages/api-client/src/generated/schemas/$Function.ts create mode 100644 packages/api-client/src/generated/schemas/$FunctionDetailResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$FunctionListResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$FunctionRun.ts create mode 100644 packages/api-client/src/generated/schemas/$FunctionRunListResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$InvokeFunctionRequest.ts create mode 100644 packages/api-client/src/generated/schemas/$UpdateFunctionRequest.ts create mode 100644 packages/api-client/src/generated/services/FunctionsService.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 46184bc..cd7ecbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac - **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test. - **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations. - **S3-compatible object storage backend** — `BACKEND_STORAGE_DRIVER=s3` swaps the local disk store for any S3-compatible API (AWS, MinIO, Garage, R2) via a stdlib-only SigV4 implementation — no AWS SDK dependency. `S3_ENDPOINT`/`S3_REGION`/`S3_BUCKET`/credentials configure it; `S3_PATH_STYLE` (default on) covers MinIO/Garage, off for AWS virtual-hosted. The configured bucket is created on startup if missing; objects stream with computed SHA-256, `storage_path` records `s3://bucket/key`. Verified live end-to-end (bucket → put → get → copy → list → delete). +- **Functions** (`core.functions` + `core.function_runs`, migration 00015) — project-scoped JS/TS executed by a runtime binary on the backend host (`bun` or `deno`, per function). The JSON payload arrives on stdin and as `PRIMORA_PAYLOAD`; stdout/stderr are captured per run (capped at 64 KiB) with status, exit code and duration — `FUNCTIONS_RUNTIME`/`FUNCTIONS_TIMEOUT_SECONDS`/`FUNCTIONS_MAX_OUTPUT_BYTES` configure the runner. API: CRUD under `/projects/:id/functions`, `POST …/invoke` (502 + recorded run on failure), `GET …/runs`. Dashboard gains a Functions page: code editor, JSON payload invoke, live result, run history. Code executes with backend privileges — the documented self-hosted trust model. ## [0.7.1] - 2026-09-23 diff --git a/ROADMAP.md b/ROADMAP.md index 1f1a698..5888b78 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -357,9 +357,11 @@ Reusable templates + an operational send log. Ordered loosely by leverage. None committed; each gets scoped when picked. -- **Phase 9 — Functions** — user code on schedule/event/HTTP triggers. - Deno or Bun isolates in a sidecar; secrets via env, payload via stdin. - The job runner already supplies the scheduling half. +- **Phase 9 — Functions** — ✅ first slice shipped: `core.functions` + + `core.function_runs`, bun/deno exec runner (stdin payload, env injection, + capped output, timeout), invoke API + dashboard page. Remaining + candidates: schedule/event/HTTP triggers riding jobs + inbound hooks, + and isolated runtimes (sidecar/Firecracker) for untrusted code. - **Phase 10 — Storage backends** — ✅ S3-compatible object storage shipped: `BACKEND_STORAGE_DRIVER=s3` + `S3_*` env vars, stdlib SigV4, verified against a real S3 server. Presigned-URL uploads for large files remain a diff --git a/apps/backend/db/migrations/00015_functions.sql b/apps/backend/db/migrations/00015_functions.sql new file mode 100644 index 0000000..9694d38 --- /dev/null +++ b/apps/backend/db/migrations/00015_functions.sql @@ -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; diff --git a/apps/backend/db/queries/functions.sql b/apps/backend/db/queries/functions.sql new file mode 100644 index 0000000..795d662 --- /dev/null +++ b/apps/backend/db/queries/functions.sql @@ -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; diff --git a/apps/backend/internal/app/app.go b/apps/backend/internal/app/app.go index 9680c83..ebf17c5 100644 --- a/apps/backend/internal/app/app.go +++ b/apps/backend/internal/app/app.go @@ -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" { diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go index f699364..da8d98e 100644 --- a/apps/backend/internal/config/config.go +++ b/apps/backend/internal/config/config.go @@ -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") diff --git a/apps/backend/internal/database/db/functions.sql.go b/apps/backend/internal/database/db/functions.sql.go new file mode 100644 index 0000000..2d97169 --- /dev/null +++ b/apps/backend/internal/database/db/functions.sql.go @@ -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 +} diff --git a/apps/backend/internal/database/db/models.go b/apps/backend/internal/database/db/models.go index 23f06dd..dce04ea 100644 --- a/apps/backend/internal/database/db/models.go +++ b/apps/backend/internal/database/db/models.go @@ -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"` diff --git a/apps/backend/internal/database/db/querier.go b/apps/backend/internal/database/db/querier.go index e99b449..e716868 100644 --- a/apps/backend/internal/database/db/querier.go +++ b/apps/backend/internal/database/db/querier.go @@ -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) diff --git a/apps/backend/internal/handlers/functions.go b/apps/backend/internal/handlers/functions.go new file mode 100644 index 0000000..3d60e45 --- /dev/null +++ b/apps/backend/internal/handlers/functions.go @@ -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}) +} diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index c6e486f..4f0378e 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -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) diff --git a/apps/backend/internal/services/functions.go b/apps/backend/internal/services/functions.go new file mode 100644 index 0000000..fa8f5e3 --- /dev/null +++ b/apps/backend/internal/services/functions.go @@ -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 +} diff --git a/apps/backend/internal/services/functions_test.go b/apps/backend/internal/services/functions_test.go new file mode 100644 index 0000000..bd7bb03 --- /dev/null +++ b/apps/backend/internal/services/functions_test.go @@ -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()) + } +} diff --git a/apps/backend/internal/services/platform_service.go b/apps/backend/internal/services/platform_service.go index e693c58..61597fb 100644 --- a/apps/backend/internal/services/platform_service.go +++ b/apps/backend/internal/services/platform_service.go @@ -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()) diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index feb7283..ca9d54b 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -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] diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 1829d5c..5cb3577 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -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: , keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") }, { id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: , keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") }, { id: "nav-vault", label: "Go to Vault", category: "Navigate", icon: , keywords: ["secrets", "env", "credentials", "api key", "token"], action: () => setActiveView("vault") }, + { id: "nav-functions", label: "Go to Functions", category: "Navigate", icon: , keywords: ["serverless", "code", "lambda", "invoke", "edge"], action: () => setActiveView("functions") }, { id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: , keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") }, { id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: , keywords: ["documents", "database"], action: () => setActiveView("collections") }, { id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: , keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") }, @@ -1784,6 +1787,14 @@ export default function App() { /> + + + + = { telemetry: "Telemetry", integrations: "Integrations", automation: "Automation", + functions: "Functions", vault: "Vault", auth: "Authentication", audit: "Audit log", diff --git a/apps/frontend/src/pages/FunctionsPage.tsx b/apps/frontend/src/pages/FunctionsPage.tsx new file mode 100644 index 0000000..b166d1e --- /dev/null +++ b/apps/frontend/src/pages/FunctionsPage.tsx @@ -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([]); + const [selected, setSelected] = createSignal(); + const [code, setCode] = createSignal(""); + const [codeDirty, setCodeDirty] = createSignal(false); + const [runs, setRuns] = createSignal([]); + const [payload, setPayload] = createSignal("{}"); + const [lastRun, setLastRun] = createSignal(); + const [message, setMessage] = createSignal(""); + const [error, setError] = createSignal(""); + const [busy, setBusy] = createSignal(); + 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 ( +
+ + + +
setMessage("")}>{message()}
+
+ +
setError("")}>{error()}
+
+ + Select a project to manage functions.
} + > + 0} + fallback={ +
+ +

No functions yet.

+

+ Functions run your code on demand — invoke them from the dashboard, the API, or wire them to schedules and hooks. +

+
+ } + > +
+
+ + + + + + + + + + + + + {(fn) => ( + void select(fn)} + > + + + + + + + )} + + +
NameRuntimeStatusUpdated
{fn.name}{fn.runtime} + {fn.enabled ? "enabled" : "disabled"} + {fmtTime(fn.updated_at)} + +
+ + +
+
+
+
+
+
+
+ + + {(fn) => ( +
+
+
+ + {fn().name} {fn().runtime} + +
+ + + + +
+
+
+
+
+ +