feat: phase 7 automation & realtime — scheduled jobs, domain events, SSE

Scheduled jobs (migration 00009): cron expressions + @every descriptors,
in-process scheduler (15s scan, missed-run collapse), signed HTTP POST
delivery (X-Primora-Signature), AES-256-GCM sealed secrets returned once,
run history with status/duration/http-code, manual execution, retention
pruning. Viewer reads, admin/developer writes.

Domain events: publishEvent fans document/object/issue/deploy/job
mutations to realtime subscribers and matching webhooks through one
path. Webhook event filter extended to job.run, document.*, object.*.

Realtime: GET /projects/:id/realtime/stream SSE with 25s keepalives and
?token=/?api_key= auth for EventSource clients.

Automation dashboard page: Schedules tab (CRUD, run history, manual run,
secret-once banner) + Live events tab (live badge, pause/clear). Demo
mode parity. primora jobs:* CLI group, six primora_*_job* MCP tools,
AutomationService in the generated client.

Fixes folded in: gzip middleware buffered SSE flushes (broke EventSource
on both streams — */stream now bypasses compression); event payloads
double-marshaled json.RawMessage as base64; compose hardcoded localhost
trusted origins so non-default ports broke sign-in; frontend auth base
URL baked an absolute localhost instead of resolving /auth on origin.

Verified live: stack up on :8085, signed delivery HMAC-checked at sink,
@every 30s schedule firing, run history rendering, document.created
landing in the live events feed.

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-18 15:15:01 +02:00
1 parent 4b7fdf35ad
commit 9e9a3d5f98
49 files changed
+3555 -32

No files matched your search

@@ -0,0 +1,441 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: jobs.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createScheduledJob = `-- name: CreateScheduledJob :one
INSERT INTO core.scheduled_jobs (
project_id,
name,
schedule,
url,
secret,
payload,
enabled,
next_run_at,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
`
type CreateScheduledJobParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Url string `json:"url"`
Secret []byte `json:"secret"`
Payload []byte `json:"payload"`
Enabled bool `json:"enabled"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
}
func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, createScheduledJob,
arg.ProjectID,
arg.Name,
arg.Schedule,
arg.Url,
arg.Secret,
arg.Payload,
arg.Enabled,
arg.NextRunAt,
arg.CreatedByUserID,
)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteScheduledJob = `-- name: DeleteScheduledJob :one
DELETE FROM core.scheduled_jobs
WHERE id = $1
AND project_id = $2
RETURNING id
`
type DeleteScheduledJobParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error) {
row := q.db.QueryRow(ctx, deleteScheduledJob, arg.ID, arg.ProjectID)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const finishScheduledJobRun = `-- name: FinishScheduledJobRun :one
UPDATE core.scheduled_job_runs
SET status = $2,
status_code = $3,
error = $4,
duration_ms = $5,
finished_at = NOW()
WHERE id = $1
RETURNING id, job_id, status, triggered_by, status_code, error, duration_ms, created_at, finished_at
`
type FinishScheduledJobRunParams struct {
ID uuid.UUID `json:"id"`
Status string `json:"status"`
StatusCode *int32 `json:"status_code"`
Error string `json:"error"`
DurationMs *int32 `json:"duration_ms"`
}
func (q *Queries) FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error) {
row := q.db.QueryRow(ctx, finishScheduledJobRun,
arg.ID,
arg.Status,
arg.StatusCode,
arg.Error,
arg.DurationMs,
)
var i CoreScheduledJobRun
err := row.Scan(
&i.ID,
&i.JobID,
&i.Status,
&i.TriggeredBy,
&i.StatusCode,
&i.Error,
&i.DurationMs,
&i.CreatedAt,
&i.FinishedAt,
)
return i, err
}
const getScheduledJobByID = `-- name: GetScheduledJobByID :one
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
WHERE id = $1
`
func (q *Queries) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, getScheduledJobByID, id)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const insertScheduledJobRun = `-- name: InsertScheduledJobRun :one
INSERT INTO core.scheduled_job_runs (job_id, triggered_by)
VALUES ($1, $2)
RETURNING id, job_id, status, triggered_by, status_code, error, duration_ms, created_at, finished_at
`
type InsertScheduledJobRunParams struct {
JobID uuid.UUID `json:"job_id"`
TriggeredBy string `json:"triggered_by"`
}
func (q *Queries) InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error) {
row := q.db.QueryRow(ctx, insertScheduledJobRun, arg.JobID, arg.TriggeredBy)
var i CoreScheduledJobRun
err := row.Scan(
&i.ID,
&i.JobID,
&i.Status,
&i.TriggeredBy,
&i.StatusCode,
&i.Error,
&i.DurationMs,
&i.CreatedAt,
&i.FinishedAt,
)
return i, err
}
const listDueScheduledJobs = `-- name: ListDueScheduledJobs :many
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
WHERE enabled
AND next_run_at IS NOT NULL
AND next_run_at <= $1
`
func (q *Queries) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error) {
rows, err := q.db.Query(ctx, listDueScheduledJobs, nextRunAt)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreScheduledJob
for rows.Next() {
var i CoreScheduledJob
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&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 listScheduledJobRuns = `-- name: ListScheduledJobRuns :many
SELECT id, job_id, status, triggered_by, status_code, error, duration_ms, created_at, finished_at FROM core.scheduled_job_runs
WHERE job_id = $1
ORDER BY created_at DESC
LIMIT $2
`
type ListScheduledJobRunsParams struct {
JobID uuid.UUID `json:"job_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error) {
rows, err := q.db.Query(ctx, listScheduledJobRuns, arg.JobID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreScheduledJobRun
for rows.Next() {
var i CoreScheduledJobRun
if err := rows.Scan(
&i.ID,
&i.JobID,
&i.Status,
&i.TriggeredBy,
&i.StatusCode,
&i.Error,
&i.DurationMs,
&i.CreatedAt,
&i.FinishedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listScheduledJobs = `-- name: ListScheduledJobs :many
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
WHERE project_id = $1
ORDER BY created_at ASC
`
func (q *Queries) ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error) {
rows, err := q.db.Query(ctx, listScheduledJobs, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreScheduledJob
for rows.Next() {
var i CoreScheduledJob
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&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 markScheduledJobRan = `-- name: MarkScheduledJobRan :one
UPDATE core.scheduled_jobs
SET last_run_at = $2,
last_status = $3,
next_run_at = $4,
updated_at = NOW()
WHERE id = $1
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
`
type MarkScheduledJobRanParams struct {
ID uuid.UUID `json:"id"`
LastRunAt pgtype.Timestamptz `json:"last_run_at"`
LastStatus string `json:"last_status"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
}
func (q *Queries) MarkScheduledJobRan(ctx context.Context, arg MarkScheduledJobRanParams) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, markScheduledJobRan,
arg.ID,
arg.LastRunAt,
arg.LastStatus,
arg.NextRunAt,
)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const pruneScheduledJobRuns = `-- name: PruneScheduledJobRuns :exec
DELETE FROM core.scheduled_job_runs r
WHERE r.job_id = $1
AND r.id NOT IN (
SELECT k.id FROM core.scheduled_job_runs k
WHERE k.job_id = $1
ORDER BY k.created_at DESC
LIMIT $2
)
`
type PruneScheduledJobRunsParams struct {
JobID uuid.UUID `json:"job_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error {
_, err := q.db.Exec(ctx, pruneScheduledJobRuns, arg.JobID, arg.Limit)
return err
}
const updateScheduledJob = `-- name: UpdateScheduledJob :one
UPDATE core.scheduled_jobs
SET name = $3,
schedule = $4,
url = $5,
enabled = $6,
next_run_at = $7,
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
updated_at = NOW()
WHERE id = $1
AND project_id = $2
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
`
type UpdateScheduledJobParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Url string `json:"url"`
Enabled bool `json:"enabled"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
Column8 bool `json:"column_8"`
Secret []byte `json:"secret"`
Column10 bool `json:"column_10"`
Payload []byte `json:"payload"`
}
func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, updateScheduledJob,
arg.ID,
arg.ProjectID,
arg.Name,
arg.Schedule,
arg.Url,
arg.Enabled,
arg.NextRunAt,
arg.Column8,
arg.Secret,
arg.Column10,
arg.Payload,
)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -307,6 +307,35 @@ type CoreProjectMember struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreScheduledJob struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Url string `json:"url"`
Secret []byte `json:"secret"`
Payload []byte `json:"payload"`
Enabled bool `json:"enabled"`
LastRunAt pgtype.Timestamptz `json:"last_run_at"`
LastStatus string `json:"last_status"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreScheduledJobRun struct {
ID uuid.UUID `json:"id"`
JobID uuid.UUID `json:"job_id"`
Status string `json:"status"`
TriggeredBy string `json:"triggered_by"`
StatusCode *int32 `json:"status_code"`
Error string `json:"error"`
DurationMs *int32 `json:"duration_ms"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
FinishedAt pgtype.Timestamptz `json:"finished_at"`
}
type CoreUser struct {
ID uuid.UUID `json:"id"`
AuthSubject string `json:"auth_subject"`
@@ -8,6 +8,7 @@ import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type Querier interface {
@@ -32,6 +33,7 @@ type Querier interface {
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error)
CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error)
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
@@ -43,9 +45,11 @@ type Querier interface {
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error)
DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error)
EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error)
EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error)
FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error)
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
@@ -63,12 +67,14 @@ type Querier interface {
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error)
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
@@ -79,6 +85,7 @@ type Querier interface {
ListComponents(ctx context.Context, projectID uuid.UUID) ([]CoreComponent, error)
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
@@ -88,14 +95,18 @@ type Querier interface {
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error)
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error)
ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error)
ListWebhookDeliveries(ctx context.Context, arg ListWebhookDeliveriesParams) ([]CoreWebhookDelivery, error)
ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]CoreWebhook, error)
ListWebhooksForEvent(ctx context.Context, arg ListWebhooksForEventParams) ([]CoreWebhook, error)
MarkInvitationAccepted(ctx context.Context, id uuid.UUID) (CoreProjectInvitation, error)
MarkScheduledJobRan(ctx context.Context, arg MarkScheduledJobRanParams) (CoreScheduledJob, error)
MarkWebhookDelivery(ctx context.Context, arg MarkWebhookDeliveryParams) error
MetricNames(ctx context.Context, projectID uuid.UUID) ([]string, error)
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
@@ -118,6 +129,7 @@ type Querier interface {
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error)
UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (CoreProjectMember, error)
UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error)
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error)
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
+7
View File
@@ -105,6 +105,13 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api.GET("/projects/:projectID/webhooks/:webhookID/deliveries", h.listWebhookDeliveries)
api.POST("/projects/:projectID/webhooks/:webhookID/test", h.testWebhook)
api.POST("/projects/:projectID/deploy-markers", h.createDeployMarker)
api.GET("/projects/:projectID/jobs", h.listJobs)
api.POST("/projects/:projectID/jobs", h.createJob)
api.PATCH("/projects/:projectID/jobs/:jobID", h.updateJob)
api.DELETE("/projects/:projectID/jobs/:jobID", h.deleteJob)
api.GET("/projects/:projectID/jobs/:jobID/runs", h.listJobRuns)
api.POST("/projects/:projectID/jobs/:jobID/run", h.runJob)
api.GET("/projects/:projectID/realtime/stream", h.realtimeStream)
api.GET("/buckets/:bucketID/objects", h.listObjects)
api.POST("/buckets/:bucketID/objects", h.uploadObject)
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
+180
View File
@@ -0,0 +1,180 @@
package handlers
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/models"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
// actorProjectJob resolves the actor, :projectID and :jobID.
func (h *HTTPHandler) actorProjectJob(c *gin.Context) (*models.Actor, uuid.UUID, uuid.UUID, bool) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return nil, uuid.Nil, uuid.Nil, false
}
jobID, ok := parseUUIDParam(c, "jobID")
if !ok {
return nil, uuid.Nil, uuid.Nil, false
}
return actor, projectID, jobID, true
}
func (h *HTTPHandler) jobError(c *gin.Context, err error) {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "job not found")
return
}
msg := err.Error()
if strings.Contains(msg, "already exists") {
apperrors.Abort(c, http.StatusConflict, "name_conflict", msg)
return
}
if strings.Contains(msg, "invalid schedule") || strings.Contains(msg, "schedule is required") || strings.Contains(msg, "https") {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
return
}
h.handleError(c, err)
}
func (h *HTTPHandler) listJobs(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.ListScheduledJobs(c.Request.Context(), actor, projectID)
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) createJob(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.CreateScheduledJobInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateScheduledJob(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusCreated, result)
}
func (h *HTTPHandler) updateJob(c *gin.Context) {
actor, projectID, jobID, ok := h.actorProjectJob(c)
if !ok {
return
}
var body services.UpdateScheduledJobInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.UpdateScheduledJob(c.Request.Context(), actor, projectID, jobID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) deleteJob(c *gin.Context) {
actor, projectID, jobID, ok := h.actorProjectJob(c)
if !ok {
return
}
if err := h.Platform.DeleteScheduledJob(c.Request.Context(), actor, projectID, jobID, middleware.RequestIDFromContext(c)); err != nil {
h.jobError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) listJobRuns(c *gin.Context) {
actor, projectID, jobID, ok := h.actorProjectJob(c)
if !ok {
return
}
limit, ok := parsePaginationQuery(c, "limit", 50, 1, 200)
if !ok {
return
}
result, err := h.Platform.ListScheduledJobRuns(c.Request.Context(), actor, projectID, jobID, limit)
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) runJob(c *gin.Context) {
actor, projectID, jobID, ok := h.actorProjectJob(c)
if !ok {
return
}
result, err := h.Platform.RunScheduledJob(c.Request.Context(), actor, projectID, jobID, middleware.RequestIDFromContext(c))
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusAccepted, result)
}
// realtimeStream pushes project domain events (document/object/issue/job) to
// the dashboard over SSE — the same pattern as telemetryStream.
func (h *HTTPHandler) realtimeStream(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
ch, err := h.Platform.SubscribeRealtime(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
defer h.Platform.UnsubscribeRealtime(projectID, ch)
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
if _, err := c.Writer.WriteString(": connected\n\n"); err == nil {
c.Writer.Flush()
}
keepalive := time.NewTicker(25 * time.Second)
defer keepalive.Stop()
c.Stream(func(w io.Writer) bool {
select {
case msg, ok := <-ch:
if !ok {
return false
}
c.SSEvent("event", json.RawMessage(msg))
return true
case <-keepalive.C:
c.SSEvent("ping", "keepalive")
return true
case <-c.Request.Context().Done():
return false
}
})
}
@@ -35,8 +35,11 @@ func Compression() gin.HandlerFunc {
return
}
// Skip compression for small responses or streaming
if c.Request.Method == "HEAD" || c.Request.URL.Path == "/api/v1/health/liveness" {
// Skip compression for small responses or streaming — gzip buffers
// writes, which breaks SSE flush semantics.
if c.Request.Method == "HEAD" ||
c.Request.URL.Path == "/api/v1/health/liveness" ||
strings.HasSuffix(c.Request.URL.Path, "/stream") {
c.Next()
return
}
+4 -2
View File
@@ -78,9 +78,11 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
apiKey = strings.TrimSpace(c.GetHeader("X-Primora-Key"))
}
authz := strings.TrimSpace(c.GetHeader("Authorization"))
// EventSource cannot set headers — the SSE stream accepts credentials
// EventSource cannot set headers — the SSE streams accept credentials
// as query params instead.
if apiKey == "" && authz == "" && strings.HasSuffix(c.Request.URL.Path, "/telemetry/stream") {
sseStream := strings.HasSuffix(c.Request.URL.Path, "/telemetry/stream") ||
strings.HasSuffix(c.Request.URL.Path, "/realtime/stream")
if apiKey == "" && authz == "" && sseStream {
apiKey = strings.TrimSpace(c.Query("api_key"))
if apiKey == "" {
if t := strings.TrimSpace(c.Query("token")); t != "" {
+591
View File
@@ -0,0 +1,591 @@
package services
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/robfig/cron/v3"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
"github.com/tdvorak/primora/apps/backend/internal/secrets"
)
// WebhookEventJobRun fires on every scheduled-job execution, successful or not.
const WebhookEventJobRun = "job.run"
// maxJobRunsPerJob bounds run history — older rows are pruned on each finish.
const maxJobRunsPerJob = 200
type ScheduledJobSummary struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
URL string `json:"url"`
Payload json.RawMessage `json:"payload"`
Enabled bool `json:"enabled"`
HasSecret bool `json:"has_secret"`
LastRunAt *time.Time `json:"last_run_at"`
LastStatus *string `json:"last_status"`
NextRunAt *time.Time `json:"next_run_at"`
CreatedAt time.Time `json:"created_at"`
// Secret is populated only when Primora generated it — one chance to see it.
Secret string `json:"secret,omitempty"`
}
type ScheduledJobRunSummary struct {
ID uuid.UUID `json:"id"`
JobID uuid.UUID `json:"job_id"`
Status string `json:"status"`
TriggeredBy string `json:"triggered_by"`
StatusCode *int32 `json:"status_code"`
Error string `json:"error"`
DurationMs *int32 `json:"duration_ms"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
type CreateScheduledJobInput struct {
Name string `json:"name" validate:"required,min=2"`
Schedule string `json:"schedule" validate:"required"`
URL string `json:"url" validate:"required,url"`
Secret string `json:"secret"`
Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"`
}
type UpdateScheduledJobInput struct {
Name *string `json:"name" validate:"omitempty,min=2"`
Schedule *string `json:"schedule"`
URL *string `json:"url" validate:"omitempty,url"`
Secret *string `json:"secret"`
Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"`
}
func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
out := ScheduledJobSummary{
ID: row.ID,
ProjectID: row.ProjectID,
Name: row.Name,
Schedule: row.Schedule,
URL: row.Url,
Payload: row.Payload,
Enabled: row.Enabled,
HasSecret: len(row.Secret) > 0,
CreatedAt: row.CreatedAt.Time,
}
if row.LastRunAt.Valid {
t := row.LastRunAt.Time
out.LastRunAt = &t
}
if row.LastStatus != "" {
s := row.LastStatus
out.LastStatus = &s
}
if row.NextRunAt.Valid {
t := row.NextRunAt.Time
out.NextRunAt = &t
}
return out
}
func toJobRunSummary(row db.CoreScheduledJobRun) ScheduledJobRunSummary {
out := ScheduledJobRunSummary{
ID: row.ID,
JobID: row.JobID,
Status: row.Status,
TriggeredBy: row.TriggeredBy,
StatusCode: row.StatusCode,
Error: row.Error,
DurationMs: row.DurationMs,
StartedAt: row.CreatedAt.Time,
}
if row.FinishedAt.Valid {
t := row.FinishedAt.Time
out.FinishedAt = &t
}
return out
}
// parseSchedule accepts standard 5-field cron expressions plus robfig
// descriptors (@hourly, @every 30m, …).
func parseSchedule(expr string) (cron.Schedule, error) {
expr = strings.TrimSpace(expr)
if expr == "" {
return nil, errors.New("schedule is required")
}
sched, err := cron.ParseStandard(expr)
if err != nil {
return nil, fmt.Errorf("invalid schedule %q — expected a cron expression like \"*/15 * * * *\" or \"@every 1h\"", expr)
}
return sched, nil
}
func nextRunAt(sched cron.Schedule, from time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: sched.Next(from), Valid: true}
}
func (s *PlatformService) jobFor(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, write bool) (db.CoreScheduledJob, error) {
roles := []string{"admin", "developer", "viewer"}
if write {
roles = []string{"admin", "developer"}
}
if err := s.requireProjectRole(ctx, actor, projectID, roles...); err != nil {
return db.CoreScheduledJob{}, err
}
row, err := s.repo.Queries().GetScheduledJobByID(ctx, jobID)
if err != nil {
return db.CoreScheduledJob{}, err
}
if row.ProjectID != projectID {
return db.CoreScheduledJob{}, fmt.Errorf("job access denied")
}
return row, nil
}
func (s *PlatformService) ListScheduledJobs(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ScheduledJobSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListScheduledJobs(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]ScheduledJobSummary, 0, len(rows))
for _, row := range rows {
out = append(out, toJobSummary(row))
}
return out, nil
}
func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateScheduledJobInput, requestID string) (ScheduledJobSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return ScheduledJobSummary{}, err
}
jobURL, err := normalizeWebhookURL(input.URL)
if err != nil {
return ScheduledJobSummary{}, err
}
sched, err := parseSchedule(input.Schedule)
if err != nil {
return ScheduledJobSummary{}, err
}
secret := strings.TrimSpace(input.Secret)
generated := false
if secret == "" {
if secret, err = randomToken(16); err != nil {
return ScheduledJobSummary{}, err
}
generated = true
}
secretBytes, err := s.enc.Encrypt([]byte(secret))
if err != nil {
return ScheduledJobSummary{}, err
}
payload := []byte("{}")
if input.Payload != nil {
payload, _ = json.Marshal(input.Payload)
}
enabled := true
if input.Enabled != nil {
enabled = *input.Enabled
}
var createdBy pgtype.UUID
if actor.UserID != nil {
createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
}
var next pgtype.Timestamptz
if enabled {
next = nextRunAt(sched, time.Now())
}
row, err := s.repo.Queries().CreateScheduledJob(ctx, db.CreateScheduledJobParams{
ProjectID: projectID,
Name: strings.TrimSpace(input.Name),
Schedule: strings.TrimSpace(input.Schedule),
Url: jobURL,
Secret: secretBytes,
Payload: payload,
Enabled: enabled,
NextRunAt: next,
CreatedByUserID: createdBy,
})
if err != nil {
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
return ScheduledJobSummary{}, fmt.Errorf("a job named %q already exists", input.Name)
}
return ScheduledJobSummary{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.created", "scheduled_job", row.ID.String(), map[string]any{
"name": row.Name, "schedule": row.Schedule, "url": row.Url,
}))
}
summary := toJobSummary(row)
if generated {
summary.Secret = secret
}
return summary, nil
}
func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, input UpdateScheduledJobInput, requestID string) (ScheduledJobSummary, error) {
row, err := s.jobFor(ctx, actor, projectID, jobID, true)
if err != nil {
return ScheduledJobSummary{}, err
}
name := row.Name
if input.Name != nil {
name = strings.TrimSpace(*input.Name)
}
schedule := row.Schedule
if input.Schedule != nil {
schedule = strings.TrimSpace(*input.Schedule)
}
jobURL := row.Url
if input.URL != nil {
if jobURL, err = normalizeWebhookURL(*input.URL); err != nil {
return ScheduledJobSummary{}, err
}
}
enabled := row.Enabled
if input.Enabled != nil {
enabled = *input.Enabled
}
sched, err := parseSchedule(schedule)
if err != nil {
return ScheduledJobSummary{}, err
}
var next pgtype.Timestamptz
if enabled {
next = nextRunAt(sched, time.Now())
}
rotateSecret := input.Secret != nil
var secretBytes []byte
if rotateSecret {
secret := strings.TrimSpace(*input.Secret)
if secret == "" {
if secret, err = randomToken(16); err != nil {
return ScheduledJobSummary{}, err
}
}
if secretBytes, err = s.enc.Encrypt([]byte(secret)); err != nil {
return ScheduledJobSummary{}, err
}
}
rotatePayload := input.Payload != nil
var payload []byte
if rotatePayload {
payload, _ = json.Marshal(input.Payload)
}
updated, err := s.repo.Queries().UpdateScheduledJob(ctx, db.UpdateScheduledJobParams{
ID: jobID,
ProjectID: projectID,
Name: name,
Schedule: schedule,
Url: jobURL,
Enabled: enabled,
NextRunAt: next,
Column8: rotateSecret,
Secret: secretBytes,
Column10: rotatePayload,
Payload: payload,
})
if err != nil {
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
return ScheduledJobSummary{}, fmt.Errorf("a job named %q already exists", name)
}
return ScheduledJobSummary{}, err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.updated", "scheduled_job", jobID.String(), map[string]any{
"name": updated.Name, "schedule": updated.Schedule, "url": updated.Url, "enabled": updated.Enabled,
}))
summary := toJobSummary(updated)
if rotateSecret && strings.TrimSpace(*input.Secret) == "" {
if plain, decErr := s.enc.Decrypt(updated.Secret); decErr == nil {
summary.Secret = string(plain)
}
}
return summary, nil
}
func (s *PlatformService) DeleteScheduledJob(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, requestID string) error {
row, err := s.jobFor(ctx, actor, projectID, jobID, true)
if err != nil {
return err
}
if _, err := s.repo.Queries().DeleteScheduledJob(ctx, db.DeleteScheduledJobParams{
ID: jobID,
ProjectID: projectID,
}); err != nil {
return err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.deleted", "scheduled_job", jobID.String(), map[string]any{
"name": row.Name,
}))
return nil
}
func (s *PlatformService) ListScheduledJobRuns(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, limit int32) ([]ScheduledJobRunSummary, error) {
if _, err := s.jobFor(ctx, actor, projectID, jobID, false); err != nil {
return nil, err
}
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.repo.Queries().ListScheduledJobRuns(ctx, db.ListScheduledJobRunsParams{
JobID: jobID,
Limit: limit,
})
if err != nil {
return nil, err
}
out := make([]ScheduledJobRunSummary, 0, len(rows))
for _, row := range rows {
out = append(out, toJobRunSummary(row))
}
return out, nil
}
// RunScheduledJob queues an out-of-band manual run. The run row is returned
// in "running" state; the result lands asynchronously.
func (s *PlatformService) RunScheduledJob(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, requestID string) (ScheduledJobRunSummary, error) {
job, err := s.jobFor(ctx, actor, projectID, jobID, true)
if err != nil {
return ScheduledJobRunSummary{}, err
}
if s.scheduler == nil {
return ScheduledJobRunSummary{}, errors.New("scheduler unavailable")
}
run, err := s.scheduler.enqueue(ctx, job, "manual")
if err != nil {
return ScheduledJobRunSummary{}, err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.run", "scheduled_job", jobID.String(), map[string]any{
"name": job.Name, "run_id": run.ID.String(),
}))
return toJobRunSummary(run), nil
}
// ---------------------------------------------------------------------------
// JobScheduler — in-process ticker, signed POST per due job, run history.
// ---------------------------------------------------------------------------
// jobQueries is the slice of db.Queries the scheduler needs — narrow so tests
// can stub it.
type jobQueries interface {
ListDueScheduledJobs(ctx context.Context, now pgtype.Timestamptz) ([]db.CoreScheduledJob, error)
InsertScheduledJobRun(ctx context.Context, params db.InsertScheduledJobRunParams) (db.CoreScheduledJobRun, error)
FinishScheduledJobRun(ctx context.Context, params db.FinishScheduledJobRunParams) (db.CoreScheduledJobRun, error)
MarkScheduledJobRan(ctx context.Context, params db.MarkScheduledJobRanParams) (db.CoreScheduledJob, error)
PruneScheduledJobRuns(ctx context.Context, params db.PruneScheduledJobRunsParams) error
}
type JobScheduler struct {
repo jobQueries
enc *secrets.Encryptor
logger *slog.Logger
http *http.Client
interval time.Duration
queue chan jobExec
quit chan struct{}
wg sync.WaitGroup
started sync.Once
// onEvent fans a finished run out to webhooks and the realtime stream.
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
}
type jobExec struct {
job db.CoreScheduledJob
run db.CoreScheduledJobRun
}
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)) *JobScheduler {
if logger == nil {
logger = slog.Default()
}
return &JobScheduler{
repo: q,
enc: enc,
logger: logger,
http: &http.Client{Timeout: 10 * time.Second},
interval: 15 * time.Second,
queue: make(chan jobExec, 256),
quit: make(chan struct{}),
onEvent: onEvent,
}
}
func (j *JobScheduler) Start(ctx context.Context) {
j.started.Do(func() {
j.wg.Add(1)
go j.run(ctx)
})
}
func (j *JobScheduler) Stop() {
close(j.quit)
j.wg.Wait()
}
func (j *JobScheduler) run(ctx context.Context) {
defer j.wg.Done()
ticker := time.NewTicker(j.interval)
defer ticker.Stop()
for {
select {
case exec := <-j.queue:
j.execute(exec.job, exec.run)
case <-ticker.C:
j.scan()
case <-j.quit:
return
case <-ctx.Done():
return
}
}
}
// scan picks up enabled jobs whose next_run_at has passed. Missed occurrences
// collapse into a single run — Primora fires once, not once per missed tick.
func (j *JobScheduler) scan() {
ctx := context.Background()
jobs, err := j.repo.ListDueScheduledJobs(ctx, pgtype.Timestamptz{Time: time.Now(), Valid: true})
if err != nil {
j.logger.Warn("scheduled job scan failed", "error", err)
return
}
for _, job := range jobs {
run, err := j.repo.InsertScheduledJobRun(ctx, db.InsertScheduledJobRunParams{JobID: job.ID, TriggeredBy: "schedule"})
if err != nil {
j.logger.Warn("job run insert failed", "job_id", job.ID, "error", err)
j.markRan(ctx, job, "failed")
continue
}
j.execute(job, run)
}
}
// enqueue inserts the run row synchronously so callers get a run ID, then
// hands the execution to the worker.
func (j *JobScheduler) enqueue(ctx context.Context, job db.CoreScheduledJob, triggeredBy string) (db.CoreScheduledJobRun, error) {
run, err := j.repo.InsertScheduledJobRun(ctx, db.InsertScheduledJobRunParams{
JobID: job.ID,
TriggeredBy: triggeredBy,
})
if err != nil {
return db.CoreScheduledJobRun{}, err
}
select {
case j.queue <- jobExec{job: job, run: run}:
case <-j.quit:
default:
j.logger.Warn("job queue full, run dropped", "job_id", job.ID, "run_id", run.ID)
}
return run, nil
}
func (j *JobScheduler) execute(job db.CoreScheduledJob, run db.CoreScheduledJobRun) {
ctx := context.Background()
start := time.Now()
statusCode, sendErr := j.deliver(ctx, job, run)
status := "success"
errText := ""
if sendErr != nil {
status = "failed"
errText = sendErr.Error()
}
duration := int32(time.Since(start).Milliseconds())
_, _ = j.repo.FinishScheduledJobRun(ctx, db.FinishScheduledJobRunParams{
ID: run.ID,
Status: status,
StatusCode: statusCode,
Error: errText,
DurationMs: &duration,
})
j.markRan(ctx, job, status)
_ = j.repo.PruneScheduledJobRuns(ctx, db.PruneScheduledJobRunsParams{JobID: job.ID, Limit: maxJobRunsPerJob})
if j.onEvent != nil {
j.onEvent(ctx, job.ProjectID, WebhookEventJobRun, map[string]any{
"job_id": job.ID.String(),
"job_name": job.Name,
"run_id": run.ID.String(),
"triggered_by": run.TriggeredBy,
"status": status,
"status_code": statusCode,
"error": errText,
"duration_ms": duration,
})
}
}
func (j *JobScheduler) markRan(ctx context.Context, job db.CoreScheduledJob, status string) {
var next pgtype.Timestamptz
if job.Enabled {
if sched, err := parseSchedule(job.Schedule); err == nil {
next = nextRunAt(sched, time.Now())
}
}
if _, err := j.repo.MarkScheduledJobRan(ctx, db.MarkScheduledJobRanParams{
ID: job.ID,
LastRunAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastStatus: status,
NextRunAt: next,
}); err != nil {
j.logger.Warn("job mark-ran failed", "job_id", job.ID, "error", err)
}
}
// deliver POSTs the job payload to its URL, signed like a webhook delivery.
func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun) (*int32, error) {
payload, _ := json.Marshal(map[string]any{
"event": WebhookEventJobRun,
"project_id": job.ProjectID.String(),
"job_id": job.ID.String(),
"job_name": job.Name,
"run_id": run.ID.String(),
"triggered_by": run.TriggeredBy,
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": json.RawMessage(job.Payload),
})
secret := ""
if len(job.Secret) > 0 && j.enc != nil {
if plain, err := j.enc.Decrypt(job.Secret); err == nil {
secret = string(plain)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, job.Url, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Primora-Event", WebhookEventJobRun)
req.Header.Set("X-Primora-Job", job.ID.String())
req.Header.Set("X-Primora-Run", run.ID.String())
req.Header.Set("X-Primora-Signature", "sha256="+signBody(secret, payload))
resp, err := j.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
code := int32(resp.StatusCode)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &code, fmt.Errorf("endpoint returned %d", resp.StatusCode)
}
return &code, nil
}
@@ -34,8 +34,10 @@ type PlatformService struct {
publicURL string
dbx *dbx.Client
hub *EventHub
realtime *EventHub
enc *secrets.Encryptor
dispatcher *WebhookDispatcher
scheduler *JobScheduler
}
type BootstrapInput struct {
@@ -222,19 +224,50 @@ type InvitationSummary struct {
}
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, 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(), enc: enc}
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc}
if enc != nil {
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
s.dispatcher.Start(context.Background())
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent)
s.scheduler.Start(context.Background())
}
return s
}
// Close stops the webhook dispatcher. Idempotent.
// Close stops the webhook dispatcher and job scheduler. Idempotent.
func (s *PlatformService) Close() {
if s.dispatcher != nil {
s.dispatcher.Stop()
}
if s.scheduler != nil {
s.scheduler.Stop()
}
}
// publishEvent fans a domain event out to realtime SSE subscribers and to
// matching webhooks. Failures are logged by the callees — events never block
// the request that produced them.
func (s *PlatformService) publishEvent(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
if s.realtime != nil {
s.realtime.Broadcast(projectID.String(), map[string]any{
"type": eventType,
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": data,
})
}
s.dispatchEvent(ctx, projectID, eventType, data)
}
// SubscribeRealtime registers an SSE consumer for project domain events.
func (s *PlatformService) SubscribeRealtime(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
return s.realtime.Subscribe(projectID.String()), nil
}
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
s.realtime.Unsubscribe(projectID.String(), ch)
}
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
@@ -1242,6 +1275,12 @@ func (s *PlatformService) UploadObject(ctx context.Context, actor *models.Actor,
"bucketId": bucket.ID.String(),
"key": objectKey,
}))
s.publishEvent(ctx, bucket.ProjectID, WebhookEventObjectCreated, map[string]any{
"bucket_id": bucket.ID.String(),
"object_key": objectKey,
"content_type": contentType,
"size_bytes": stored.SizeBytes,
})
return row, nil
}
@@ -1363,6 +1402,12 @@ func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor,
"oldObjectKey": oldObject.ObjectKey,
"newObjectKey": updated.ObjectKey,
}))
s.publishEvent(ctx, sourceBucket.ProjectID, WebhookEventObjectUpdated, map[string]any{
"bucket_id": destinationBucket.ID.String(),
"object_key": updated.ObjectKey,
"old_bucket_id": sourceBucket.ID.String(),
"old_object_key": oldObject.ObjectKey,
})
return updated, nil
}
@@ -1444,6 +1489,14 @@ func (s *PlatformService) CopyObject(ctx context.Context, actor *models.Actor, b
"sourceObjectKey": sourceObjectKey,
"newObjectKey": created.ObjectKey,
}))
s.publishEvent(ctx, sourceBucket.ProjectID, WebhookEventObjectCreated, map[string]any{
"bucket_id": destinationBucket.ID.String(),
"object_key": created.ObjectKey,
"content_type": created.ContentType,
"size_bytes": created.SizeBytes,
"source_bucket_id": sourceBucket.ID.String(),
"source_object_key": sourceObjectKey,
})
return created, nil
}
@@ -1469,6 +1522,10 @@ func (s *PlatformService) DeleteObject(ctx context.Context, actor *models.Actor,
"bucketId": bucketID.String(),
"key": objectKey,
}))
s.publishEvent(ctx, bucket.ProjectID, WebhookEventObjectDeleted, map[string]any{
"bucket_id": bucketID.String(),
"object_key": objectKey,
})
return nil
}
@@ -1692,6 +1749,11 @@ func (s *PlatformService) CreateDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.created", "document", row.ID.String(), map[string]any{
"collectionId": collectionID.String(),
}))
s.publishEvent(ctx, collection.ProjectID, WebhookEventDocumentCreated, map[string]any{
"collection_id": collectionID.String(),
"document_id": row.ID.String(),
"document": json.RawMessage(row.Data),
})
return row, nil
}
@@ -1716,6 +1778,11 @@ func (s *PlatformService) UpdateDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.updated", "document", documentID.String(), map[string]any{
"collectionId": collectionID.String(),
}))
s.publishEvent(ctx, collection.ProjectID, WebhookEventDocumentUpdated, map[string]any{
"collection_id": collectionID.String(),
"document_id": documentID.String(),
"document": json.RawMessage(row.Data),
})
return row, nil
}
@@ -1737,6 +1804,10 @@ func (s *PlatformService) DeleteDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.deleted", "document", documentID.String(), map[string]any{
"collectionId": collectionID.String(),
}))
s.publishEvent(ctx, collection.ProjectID, WebhookEventDocumentDeleted, map[string]any{
"collection_id": collectionID.String(),
"document_id": documentID.String(),
})
return nil
}
+2 -2
View File
@@ -192,9 +192,9 @@ func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor,
for _, ev := range inserted {
s.hub.Broadcast(ev.ProjectID.String(), ev)
}
// A fresh error fingerprint is a new issue group — notify webhooks.
// A fresh error fingerprint is a new issue group — notify webhooks + SSE.
for _, issue := range newIssues {
s.dispatchEvent(ctx, projectID, WebhookEventIssueCreated, map[string]any{
s.publishEvent(ctx, projectID, WebhookEventIssueCreated, map[string]any{
"fingerprint": issue.Fingerprint,
"message": issue.Message,
"severity": issue.Severity,
+20 -7
View File
@@ -28,15 +28,28 @@ import (
)
const (
WebhookEventIssueCreated = "issue.created"
WebhookEventDeployMarker = "deploy.marker"
WebhookEventTest = "webhook.test"
WebhookEventIssueCreated = "issue.created"
WebhookEventDeployMarker = "deploy.marker"
WebhookEventTest = "webhook.test"
WebhookEventDocumentCreated = "document.created"
WebhookEventDocumentUpdated = "document.updated"
WebhookEventDocumentDeleted = "document.deleted"
WebhookEventObjectCreated = "object.created"
WebhookEventObjectUpdated = "object.updated"
WebhookEventObjectDeleted = "object.deleted"
)
var webhookEventTypes = map[string]bool{
WebhookEventIssueCreated: true,
WebhookEventDeployMarker: true,
WebhookEventTest: true,
WebhookEventIssueCreated: true,
WebhookEventDeployMarker: true,
WebhookEventTest: true,
WebhookEventJobRun: true,
WebhookEventDocumentCreated: true,
WebhookEventDocumentUpdated: true,
WebhookEventDocumentDeleted: true,
WebhookEventObjectCreated: true,
WebhookEventObjectUpdated: true,
WebhookEventObjectDeleted: true,
}
const webhookMaxAttempts = 3
@@ -436,7 +449,7 @@ func (s *PlatformService) CreateDeployMarker(ctx context.Context, actor *models.
if _, err := s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "deploy.marker", "deploy_marker", marker.Ref+marker.Version, meta)); err != nil {
return DeployMarker{}, err
}
s.dispatchEvent(ctx, projectID, WebhookEventDeployMarker, map[string]any{
s.publishEvent(ctx, projectID, WebhookEventDeployMarker, map[string]any{
"version": marker.Version,
"ref": marker.Ref,
"environment": marker.Environment,