package repository import ( "context" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/geo-platform/tenant-api/internal/ops/domain" ) var ErrJobNotFound = errors.New("job not found") type JobRepository struct { pool *pgxpool.Pool monitoringPool *pgxpool.Pool } func NewJobRepository(pool, monitoringPool *pgxpool.Pool) *JobRepository { return &JobRepository{pool: pool, monitoringPool: monitoringPool} } func (r *JobRepository) List(ctx context.Context, f domain.JobFilter) ([]domain.JobSummary, int64, domain.JobCounts, error) { limit := f.Limit if limit <= 0 || limit > 500 { limit = 50 } offset := f.Offset if offset < 0 { offset = 0 } businessItems, businessErr := r.listBusinessJobs(ctx, f) if businessErr != nil { return nil, 0, domain.JobCounts{}, businessErr } monitoringItems, monitoringErr := r.listMonitoringJobs(ctx, f) if monitoringErr != nil { return nil, 0, domain.JobCounts{}, monitoringErr } all := append(businessItems, monitoringItems...) sortJobs(all) counts := buildJobCounts(all) total := int64(len(all)) if offset >= len(all) { return []domain.JobSummary{}, total, counts, nil } end := offset + limit if end > len(all) { end = len(all) } return all[offset:end], total, counts, nil } func (r *JobRepository) Get(ctx context.Context, source, id string) (*domain.JobDetail, error) { source = normalizeJobSource(source) id = strings.TrimSpace(id) switch source { case "generation": return r.getGenerationJob(ctx, id) case "template_assist": return r.getTemplateAssistJob(ctx, id) case "kol_assist": return r.getKolAssistJob(ctx, id) case "knowledge_parse": return r.getKnowledgeParseJob(ctx, id) case "desktop_publish": return r.getDesktopPublishJob(ctx, id) case "desktop_task": return r.getDesktopTaskJob(ctx, id) case "compliance_review": return r.getComplianceReviewJob(ctx, id) case "monitoring_collect": return r.getMonitoringCollectJob(ctx, id) default: return nil, ErrJobNotFound } } func (r *JobRepository) Retry(ctx context.Context, source, id string) (*domain.JobDetail, error) { source = normalizeJobSource(source) switch source { case "generation": return r.retryGenerationJob(ctx, id) case "template_assist": return r.retryTemplateAssistJob(ctx, id) case "kol_assist": return r.retryKolAssistJob(ctx, id) case "knowledge_parse": return r.retryKnowledgeParseJob(ctx, id) case "desktop_task": return r.retryDesktopTaskJob(ctx, id) case "compliance_review": return r.retryComplianceReviewJob(ctx, id) case "monitoring_collect": return r.retryMonitoringCollectJob(ctx, id) default: return nil, ErrJobNotFound } } func (r *JobRepository) Cancel(ctx context.Context, source, id, reason string) (*domain.JobDetail, error) { source = normalizeJobSource(source) reason = strings.TrimSpace(reason) if reason == "" { reason = "cancelled from ops job console" } switch source { case "generation": return r.cancelGenerationJob(ctx, id, reason) case "template_assist": return r.cancelTemplateAssistJob(ctx, id, reason) case "kol_assist": return r.cancelKolAssistJob(ctx, id, reason) case "knowledge_parse": return r.cancelKnowledgeParseJob(ctx, id, reason) case "desktop_publish": return r.cancelDesktopPublishJob(ctx, id, reason) case "desktop_task": return r.cancelDesktopTaskJob(ctx, id, reason) case "compliance_review": return r.cancelComplianceReviewJob(ctx, id, reason) case "monitoring_collect": return r.cancelMonitoringCollectJob(ctx, id, reason) default: return nil, ErrJobNotFound } } func (r *JobRepository) listBusinessJobs(ctx context.Context, f domain.JobFilter) ([]domain.JobSummary, error) { if r == nil || r.pool == nil { return nil, nil } args := make([]any, 0) sourceFilter := normalizeJobSource(f.Source) sourcePredicate := "TRUE" if sourceFilter != "" { args = append(args, sourceFilter) sourcePredicate = fmt.Sprintf("source = $%d", len(args)) } sourceArg := len(args) + 1 args = append(args, nullableTextValue(sourceFilter)) phaseArg := len(args) + 1 args = append(args, nullableTextValue(f.Phase)) statusArg := len(args) + 1 args = append(args, nullableTextValue(f.Status)) kindArg := len(args) + 1 args = append(args, nullableTextValue(f.Kind)) keywordArg := len(args) + 1 args = append(args, nullableKeyword(f.Keyword)) tenantArg := len(args) + 1 args = append(args, f.TenantID) workspaceArg := len(args) + 1 args = append(args, f.WorkspaceID) userArg := len(args) + 1 args = append(args, f.UserID) startArg := len(args) + 1 args = append(args, f.StartAt) endArg := len(args) + 1 args = append(args, f.EndAt) rows, err := r.pool.Query(ctx, fmt.Sprintf(` WITH jobs AS ( %s ) SELECT source, id, numeric_id, uuid, kind, status, phase, tenant_id, tenant_name, workspace_id, workspace_name, user_id, user_name, title, target, queue, worker, attempts, priority, error_message, created_at, updated_at, started_at, completed_at, lease_expires_at, available_at, last_heartbeat_at FROM jobs WHERE %s AND ($%d::text IS NULL OR source = $%d) AND ($%d::text IS NULL OR phase = $%d) AND ($%d::text IS NULL OR status = $%d) AND ($%d::text IS NULL OR kind ILIKE '%%' || $%d || '%%') AND ($%d::text IS NULL OR id ILIKE '%%' || $%d || '%%' OR COALESCE(uuid, '') ILIKE '%%' || $%d || '%%' OR COALESCE(title, '') ILIKE '%%' || $%d || '%%' OR COALESCE(target, '') ILIKE '%%' || $%d || '%%' OR COALESCE(error_message, '') ILIKE '%%' || $%d || '%%' OR COALESCE(tenant_name, '') ILIKE '%%' || $%d || '%%' OR COALESCE(workspace_name, '') ILIKE '%%' || $%d || '%%' OR COALESCE(user_name, '') ILIKE '%%' || $%d || '%%') AND ($%d::bigint IS NULL OR tenant_id = $%d) AND ($%d::bigint IS NULL OR workspace_id = $%d) AND ($%d::bigint IS NULL OR user_id = $%d) AND ($%d::timestamptz IS NULL OR created_at >= $%d) AND ($%d::timestamptz IS NULL OR created_at < $%d) ORDER BY created_at DESC, source ASC, id DESC LIMIT 2000`, businessJobUnionSQL(), sourcePredicate, sourceArg, sourceArg, phaseArg, phaseArg, statusArg, statusArg, kindArg, kindArg, keywordArg, keywordArg, keywordArg, keywordArg, keywordArg, keywordArg, keywordArg, keywordArg, keywordArg, tenantArg, tenantArg, workspaceArg, workspaceArg, userArg, userArg, startArg, startArg, endArg, endArg, ), args...) if err != nil { return nil, err } defer rows.Close() items := make([]domain.JobSummary, 0) for rows.Next() { item, err := scanJobSummary(rows) if err != nil { return nil, err } items = append(items, *item) } return items, rows.Err() } func (r *JobRepository) listMonitoringJobs(ctx context.Context, f domain.JobFilter) ([]domain.JobSummary, error) { if r == nil || r.monitoringPool == nil { return nil, nil } sourceFilter := normalizeJobSource(f.Source) if sourceFilter != "" && sourceFilter != "monitoring_collect" { return nil, nil } args := make([]any, 0) phaseArg := len(args) + 1 args = append(args, nullableTextValue(f.Phase)) statusArg := len(args) + 1 args = append(args, nullableTextValue(f.Status)) kindArg := len(args) + 1 args = append(args, nullableTextValue(f.Kind)) keywordArg := len(args) + 1 args = append(args, nullableKeyword(f.Keyword)) tenantArg := len(args) + 1 args = append(args, f.TenantID) workspaceArg := len(args) + 1 args = append(args, f.WorkspaceID) userArg := len(args) + 1 args = append(args, f.UserID) startArg := len(args) + 1 args = append(args, f.StartAt) endArg := len(args) + 1 args = append(args, f.EndAt) rows, err := r.monitoringPool.Query(ctx, fmt.Sprintf(` WITH jobs AS ( SELECT 'monitoring_collect'::text AS source, t.id::text AS id, t.id::bigint AS numeric_id, NULL::text AS uuid, ('monitor:' || t.ai_platform_id || ':' || t.run_mode)::text AS kind, t.status::text AS status, CASE WHEN t.status IN ('pending', 'accepted', 'dispatching') THEN 'queued' WHEN t.status IN ('leased', 'running', 'received') THEN 'running' WHEN t.status IN ('completed', 'succeeded') THEN 'succeeded' WHEN t.status IN ('failed', 'expired') THEN 'failed' WHEN t.status IN ('skipped', 'superseded') THEN 'canceled' ELSE 'unknown' END AS phase, t.tenant_id::bigint AS tenant_id, NULL::text AS tenant_name, r.workspace_id::bigint AS workspace_id, NULL::text AS workspace_name, r.requested_by_user_id::bigint AS user_id, NULL::text AS user_name, ('Brand #' || t.brand_id || ' / Question #' || t.question_id)::text AS title, (t.ai_platform_id || ' / ' || t.business_date::text)::text AS target, ('monitoring:' || COALESCE(t.dispatch_lane, 'normal'))::text AS queue, COALESCE(t.leased_to_executor, t.target_client_id::text)::text AS worker, COALESCE(t.dispatch_attempts, 0)::int AS attempts, COALESCE(t.dispatch_priority, 100)::int AS priority, NULLIF(COALESCE(t.error_message, t.skip_reason), '')::text AS error_message, t.created_at, t.updated_at, t.leased_at AS started_at, COALESCE(t.completed_at, t.callback_received_at) AS completed_at, t.lease_expires_at, COALESCE(t.dispatch_after, t.planned_at) AS available_at, NULL::timestamptz AS last_heartbeat_at FROM monitoring_collect_tasks t LEFT JOIN monitoring_collect_requests r ON r.request_id = t.superseded_by_request_id ) SELECT source, id, numeric_id, uuid, kind, status, phase, tenant_id, tenant_name, workspace_id, workspace_name, user_id, user_name, title, target, queue, worker, attempts, priority, error_message, created_at, updated_at, started_at, completed_at, lease_expires_at, available_at, last_heartbeat_at FROM jobs WHERE ($%d::text IS NULL OR phase = $%d) AND ($%d::text IS NULL OR status = $%d) AND ($%d::text IS NULL OR kind ILIKE '%%' || $%d || '%%') AND ($%d::text IS NULL OR id ILIKE '%%' || $%d || '%%' OR COALESCE(title, '') ILIKE '%%' || $%d || '%%' OR COALESCE(target, '') ILIKE '%%' || $%d || '%%' OR COALESCE(error_message, '') ILIKE '%%' || $%d || '%%') AND ($%d::bigint IS NULL OR tenant_id = $%d) AND ($%d::bigint IS NULL OR workspace_id = $%d) AND ($%d::bigint IS NULL OR user_id = $%d) AND ($%d::timestamptz IS NULL OR created_at >= $%d) AND ($%d::timestamptz IS NULL OR created_at < $%d) ORDER BY created_at DESC, id DESC LIMIT 2000`, phaseArg, phaseArg, statusArg, statusArg, kindArg, kindArg, keywordArg, keywordArg, keywordArg, keywordArg, keywordArg, tenantArg, tenantArg, workspaceArg, workspaceArg, userArg, userArg, startArg, startArg, endArg, endArg, ), args...) if err != nil { if isUndefinedMonitoringRelation(err) { return nil, nil } return nil, err } defer rows.Close() items := make([]domain.JobSummary, 0) for rows.Next() { item, err := scanJobSummary(rows) if err != nil { return nil, err } items = append(items, *item) } return items, rows.Err() } func businessJobUnionSQL() string { return strings.Join([]string{ generationJobSelectSQL(), templateAssistJobSelectSQL(), kolAssistJobSelectSQL(), knowledgeParseJobSelectSQL(), desktopPublishJobSelectSQL(), desktopTaskJobSelectSQL(), complianceReviewJobSelectSQL(), }, "\nUNION ALL\n") } func generationJobSelectSQL() string { return ` SELECT 'generation'::text AS source, gt.id::text AS id, gt.id::bigint AS numeric_id, NULL::text AS uuid, gt.task_type::text AS kind, gt.status::text AS status, CASE WHEN gt.status = 'queued' THEN 'queued' WHEN gt.status = 'running' THEN 'running' WHEN gt.status = 'completed' THEN 'succeeded' WHEN gt.status = 'failed' THEN 'failed' WHEN gt.status IN ('cancelled', 'canceled', 'aborted') THEN 'canceled' ELSE 'unknown' END AS phase, gt.tenant_id::bigint AS tenant_id, t.name::text AS tenant_name, w.id::bigint AS workspace_id, w.name::text AS workspace_name, gt.operator_id::bigint AS user_id, u.name::text AS user_name, COALESCE(av.title, 'Article #' || gt.article_id::text, 'Generation task #' || gt.id::text)::text AS title, CASE WHEN gt.article_id IS NOT NULL THEN 'article #' || gt.article_id::text ELSE NULL END::text AS target, 'generation.task.run'::text AS queue, gt.lease_owner::text AS worker, COALESCE(gt.attempt_count, 0)::int AS attempts, NULL::int AS priority, NULLIF(gt.error_message, '')::text AS error_message, gt.created_at, gt.updated_at, gt.started_at, gt.completed_at, gt.lease_expires_at, NULL::timestamptz AS available_at, gt.last_heartbeat_at FROM generation_tasks gt LEFT JOIN tenants t ON t.id = gt.tenant_id LEFT JOIN users u ON u.id = gt.operator_id LEFT JOIN articles a ON a.id = gt.article_id LEFT JOIN article_versions av ON av.id = a.current_version_id LEFT JOIN LATERAL ( SELECT wm.workspace_id AS id, ws.name FROM workspace_memberships wm JOIN workspaces ws ON ws.id = wm.workspace_id WHERE wm.tenant_id = gt.tenant_id AND wm.user_id = gt.operator_id AND wm.is_primary = TRUE ORDER BY wm.created_at ASC, wm.id ASC LIMIT 1 ) w ON TRUE` } func templateAssistJobSelectSQL() string { return ` SELECT 'template_assist'::text AS source, tat.id::text AS id, NULL::bigint AS numeric_id, tat.id::text AS uuid, tat.task_type::text AS kind, tat.status::text AS status, CASE WHEN tat.status = 'queued' THEN 'queued' WHEN tat.status = 'running' THEN 'running' WHEN tat.status = 'completed' THEN 'succeeded' WHEN tat.status = 'failed' THEN 'failed' WHEN tat.status IN ('cancelled', 'canceled', 'aborted') THEN 'canceled' ELSE 'unknown' END AS phase, tat.tenant_id::bigint AS tenant_id, t.name::text AS tenant_name, w.id::bigint AS workspace_id, w.name::text AS workspace_name, tat.operator_id::bigint AS user_id, u.name::text AS user_name, COALESCE(at.template_name, 'Template #' || tat.template_id::text)::text AS title, ('template #' || tat.template_id::text)::text AS target, 'template.assist.run'::text AS queue, NULL::text AS worker, NULL::int AS attempts, NULL::int AS priority, NULLIF(tat.error_message, '')::text AS error_message, tat.created_at, tat.updated_at, tat.started_at, tat.completed_at, NULL::timestamptz AS lease_expires_at, NULL::timestamptz AS available_at, NULL::timestamptz AS last_heartbeat_at FROM template_assist_tasks tat LEFT JOIN tenants t ON t.id = tat.tenant_id LEFT JOIN users u ON u.id = tat.operator_id LEFT JOIN article_templates at ON at.id = tat.template_id LEFT JOIN LATERAL ( SELECT wm.workspace_id AS id, ws.name FROM workspace_memberships wm JOIN workspaces ws ON ws.id = wm.workspace_id WHERE wm.tenant_id = tat.tenant_id AND wm.user_id = tat.operator_id AND wm.is_primary = TRUE ORDER BY wm.created_at ASC, wm.id ASC LIMIT 1 ) w ON TRUE` } func kolAssistJobSelectSQL() string { return ` SELECT 'kol_assist'::text AS source, kat.id::text AS id, NULL::bigint AS numeric_id, kat.id::text AS uuid, kat.task_type::text AS kind, kat.status::text AS status, CASE WHEN kat.status = 'queued' THEN 'queued' WHEN kat.status = 'running' THEN 'running' WHEN kat.status = 'completed' THEN 'succeeded' WHEN kat.status = 'failed' THEN 'failed' WHEN kat.status IN ('cancelled', 'canceled', 'aborted') THEN 'canceled' ELSE 'unknown' END AS phase, kat.tenant_id::bigint AS tenant_id, t.name::text AS tenant_name, w.id::bigint AS workspace_id, w.name::text AS workspace_name, kat.operator_id::bigint AS user_id, u.name::text AS user_name, COALESCE(kpr.name, kp.display_name, 'KOL assist #' || kat.id)::text AS title, COALESCE(kp.display_name, 'profile #' || kat.kol_profile_id::text)::text AS target, 'kol.assist.run'::text AS queue, NULL::text AS worker, NULL::int AS attempts, NULL::int AS priority, NULLIF(kat.error_message, '')::text AS error_message, kat.created_at, kat.updated_at, kat.started_at, kat.completed_at, NULL::timestamptz AS lease_expires_at, NULL::timestamptz AS available_at, NULL::timestamptz AS last_heartbeat_at FROM kol_assist_tasks kat LEFT JOIN tenants t ON t.id = kat.tenant_id LEFT JOIN users u ON u.id = kat.operator_id LEFT JOIN kol_profiles kp ON kp.id = kat.kol_profile_id LEFT JOIN kol_prompts kpr ON kpr.id = kat.prompt_id LEFT JOIN LATERAL ( SELECT wm.workspace_id AS id, ws.name FROM workspace_memberships wm JOIN workspaces ws ON ws.id = wm.workspace_id WHERE wm.tenant_id = kat.tenant_id AND wm.user_id = kat.operator_id AND wm.is_primary = TRUE ORDER BY wm.created_at ASC, wm.id ASC LIMIT 1 ) w ON TRUE` } func knowledgeParseJobSelectSQL() string { return ` SELECT 'knowledge_parse'::text AS source, kpt.id::text AS id, kpt.id::bigint AS numeric_id, NULL::text AS uuid, ('knowledge:' || kpt.source_type)::text AS kind, kpt.status::text AS status, CASE WHEN kpt.status = 'pending' THEN 'queued' WHEN kpt.status = 'running' THEN 'running' WHEN kpt.status = 'completed' THEN 'succeeded' WHEN kpt.status = 'failed' THEN 'failed' WHEN kpt.status IN ('deleted', 'cancelled', 'canceled', 'aborted') THEN 'canceled' ELSE 'unknown' END AS phase, kpt.tenant_id::bigint AS tenant_id, t.name::text AS tenant_name, NULL::bigint AS workspace_id, NULL::text AS workspace_name, NULL::bigint AS user_id, NULL::text AS user_name, COALESCE(ki.name, 'Knowledge item #' || kpt.knowledge_item_id::text)::text AS title, ('knowledge item #' || kpt.knowledge_item_id::text)::text AS target, 'in_process.knowledge_parse'::text AS queue, NULL::text AS worker, NULL::int AS attempts, NULL::int AS priority, NULLIF(kpt.error_message, '')::text AS error_message, kpt.created_at, kpt.updated_at, kpt.started_at, kpt.completed_at, NULL::timestamptz AS lease_expires_at, NULL::timestamptz AS available_at, NULL::timestamptz AS last_heartbeat_at FROM knowledge_parse_tasks kpt LEFT JOIN tenants t ON t.id = kpt.tenant_id LEFT JOIN knowledge_items ki ON ki.id = kpt.knowledge_item_id` } func desktopPublishJobSelectSQL() string { return ` SELECT 'desktop_publish'::text AS source, j.desktop_id::text AS id, j.id::bigint AS numeric_id, j.desktop_id::text AS uuid, 'publish_job'::text AS kind, COALESCE(j.status, 'queued')::text AS status, CASE WHEN COALESCE(j.status, 'queued') = 'queued' THEN 'queued' WHEN j.status = 'blocked_by_compliance' THEN 'blocked' WHEN j.status IN ('completed', 'succeeded') THEN 'succeeded' WHEN j.status = 'failed' THEN 'failed' WHEN j.status IN ('cancelled', 'canceled', 'aborted') THEN 'canceled' ELSE 'unknown' END AS phase, j.tenant_id::bigint AS tenant_id, t.name::text AS tenant_name, j.workspace_id::bigint AS workspace_id, w.name::text AS workspace_name, j.created_by_user_id::bigint AS user_id, u.name::text AS user_name, j.title::text AS title, CASE WHEN j.article_id IS NOT NULL THEN 'article #' || j.article_id::text ELSE NULL END::text AS target, 'desktop.publish'::text AS queue, NULL::text AS worker, NULL::int AS attempts, NULL::int AS priority, NULLIF(j.compliance_blocked_reason, '')::text AS error_message, j.created_at, j.updated_at, NULL::timestamptz AS started_at, NULL::timestamptz AS completed_at, NULL::timestamptz AS lease_expires_at, j.scheduled_at AS available_at, NULL::timestamptz AS last_heartbeat_at FROM desktop_publish_jobs j LEFT JOIN tenants t ON t.id = j.tenant_id LEFT JOIN workspaces w ON w.id = j.workspace_id LEFT JOIN users u ON u.id = j.created_by_user_id` } func desktopTaskJobSelectSQL() string { return ` SELECT 'desktop_task'::text AS source, dt.desktop_id::text AS id, dt.id::bigint AS numeric_id, dt.desktop_id::text AS uuid, dt.kind::text AS kind, dt.status::text AS status, CASE WHEN dt.status = 'queued' THEN 'queued' WHEN dt.status = 'in_progress' THEN 'running' WHEN dt.status = 'succeeded' THEN 'succeeded' WHEN dt.status = 'failed' THEN 'failed' WHEN dt.status = 'aborted' THEN 'canceled' WHEN dt.status = 'unknown' THEN 'unknown' ELSE 'unknown' END AS phase, dt.tenant_id::bigint AS tenant_id, t.name::text AS tenant_name, dt.workspace_id::bigint AS workspace_id, w.name::text AS workspace_name, dc.user_id::bigint AS user_id, u.name::text AS user_name, COALESCE(j.title, dt.kind || ' task #' || dt.id::text)::text AS title, COALESCE(mp.name, dt.platform_id, dt.target_client_id::text)::text AS target, ('desktop.' || COALESCE(dt.lane, 'normal'))::text AS queue, dt.target_client_id::text AS worker, COALESCE(dt.attempts, 0)::int AS attempts, COALESCE(dt.priority, 0)::int AS priority, NULLIF(dt.error::text, '')::text AS error_message, dt.created_at, dt.updated_at, dt.started_at, CASE WHEN dt.status IN ('succeeded','failed','aborted') THEN dt.updated_at ELSE NULL END AS completed_at, dt.lease_expires_at, dt.enqueued_at AS available_at, NULL::timestamptz AS last_heartbeat_at FROM desktop_tasks dt LEFT JOIN desktop_publish_jobs j ON j.desktop_id = dt.job_id LEFT JOIN tenants t ON t.id = dt.tenant_id LEFT JOIN workspaces w ON w.id = dt.workspace_id LEFT JOIN desktop_clients dc ON dc.id = dt.target_client_id LEFT JOIN users u ON u.id = dc.user_id LEFT JOIN media_platforms mp ON mp.platform_id = dt.platform_id` } func complianceReviewJobSelectSQL() string { return ` SELECT 'compliance_review'::text AS source, crj.id::text AS id, crj.id::bigint AS numeric_id, crj.message_id::text AS uuid, 'compliance_review'::text AS kind, crj.status::text AS status, CASE WHEN crj.status = 'pending' THEN 'queued' WHEN crj.status = 'running' THEN 'running' WHEN crj.status = 'done' THEN 'succeeded' WHEN crj.status = 'failed' THEN 'failed' ELSE 'unknown' END AS phase, crj.tenant_id::bigint AS tenant_id, t.name::text AS tenant_name, w.id::bigint AS workspace_id, w.name::text AS workspace_name, ccr.checked_by::bigint AS user_id, u.name::text AS user_name, COALESCE(av.title, 'Compliance review #' || crj.id::text)::text AS title, ('check record #' || crj.check_record_id::text)::text AS target, 'compliance.review.run'::text AS queue, crj.locked_by::text AS worker, COALESCE(crj.attempts, 0)::int AS attempts, NULL::int AS priority, NULLIF(crj.last_error, '')::text AS error_message, crj.created_at, crj.updated_at, crj.locked_at AS started_at, CASE WHEN crj.status IN ('done','failed') THEN crj.updated_at ELSE NULL END AS completed_at, NULL::timestamptz AS lease_expires_at, crj.available_at, NULL::timestamptz AS last_heartbeat_at FROM compliance_review_jobs crj LEFT JOIN tenants t ON t.id = crj.tenant_id LEFT JOIN compliance_check_records ccr ON ccr.id = crj.check_record_id LEFT JOIN users u ON u.id = ccr.checked_by LEFT JOIN article_versions av ON av.id = crj.article_version_id LEFT JOIN LATERAL ( SELECT wm.workspace_id AS id, ws.name FROM workspace_memberships wm JOIN workspaces ws ON ws.id = wm.workspace_id WHERE wm.tenant_id = crj.tenant_id AND wm.user_id = ccr.checked_by AND wm.is_primary = TRUE ORDER BY wm.created_at ASC, wm.id ASC LIMIT 1 ) w ON TRUE` } func (r *JobRepository) getGenerationJob(ctx context.Context, id string) (*domain.JobDetail, error) { jobID, err := parseInt64ID(id) if err != nil { return nil, ErrJobNotFound } return r.getBusinessJob(ctx, "generation", jobID, ` SELECT gt.input_params_json, NULL::jsonb, CASE WHEN gt.error_message IS NULL OR gt.error_message = '' THEN NULL ELSE to_jsonb(gt.error_message) END, jsonb_strip_nulls(jsonb_build_object( 'article_id', gt.article_id, 'quota_reservation_id', gt.quota_reservation_id, 'request_hash', gt.request_hash, 'task_batch_id', gt.task_batch_id )) FROM generation_tasks gt WHERE gt.id = $1`, jobID) } func (r *JobRepository) getTemplateAssistJob(ctx context.Context, id string) (*domain.JobDetail, error) { return r.getBusinessJobByTextID(ctx, "template_assist", id, ` SELECT tat.request_json, tat.result_json, CASE WHEN tat.error_message IS NULL OR tat.error_message = '' THEN NULL ELSE to_jsonb(tat.error_message) END, jsonb_strip_nulls(jsonb_build_object( 'template_id', tat.template_id, 'template_key', at.template_key, 'template_name', at.template_name, 'analyze_prompt', COALESCE( at.card_config_json #>> '{wizard,structure,analyze_prompt_template}', at.card_config_json #>> '{wizard,analyze_prompt_template}' ), 'title_prompt', COALESCE( at.card_config_json #>> '{wizard,structure,title_prompt_template}', at.card_config_json #>> '{wizard,title_prompt_template}' ), 'outline_prompt', COALESCE( at.card_config_json #>> '{wizard,structure,outline_prompt_template}', at.card_config_json #>> '{wizard,outline_prompt_template}' ) )) FROM template_assist_tasks tat LEFT JOIN article_templates at ON at.id = tat.template_id WHERE tat.id = $1`) } func (r *JobRepository) getKolAssistJob(ctx context.Context, id string) (*domain.JobDetail, error) { return r.getBusinessJobByTextID(ctx, "kol_assist", id, ` SELECT kat.request_json, kat.result_json, CASE WHEN kat.error_message IS NULL OR kat.error_message = '' THEN NULL ELSE to_jsonb(kat.error_message) END, jsonb_strip_nulls(jsonb_build_object( 'kol_profile_id', kat.kol_profile_id, 'prompt_id', kat.prompt_id )) FROM kol_assist_tasks kat WHERE kat.id = $1`) } func (r *JobRepository) getKnowledgeParseJob(ctx context.Context, id string) (*domain.JobDetail, error) { jobID, err := parseInt64ID(id) if err != nil { return nil, ErrJobNotFound } return r.getBusinessJob(ctx, "knowledge_parse", jobID, ` SELECT jsonb_strip_nulls(jsonb_build_object( 'knowledge_item_id', kpt.knowledge_item_id, 'source_type', kpt.source_type, 'source_uri', ki.source_uri, 'storage_key', ki.storage_key )), NULL::jsonb, CASE WHEN kpt.error_message IS NULL OR kpt.error_message = '' THEN NULL ELSE to_jsonb(kpt.error_message) END, jsonb_strip_nulls(jsonb_build_object( 'group_id', ki.group_id, 'item_status', ki.status, 'size_bytes', ki.size_bytes, 'item_version', ki.item_version )) FROM knowledge_parse_tasks kpt LEFT JOIN knowledge_items ki ON ki.id = kpt.knowledge_item_id WHERE kpt.id = $1`, jobID) } func (r *JobRepository) getDesktopPublishJob(ctx context.Context, id string) (*domain.JobDetail, error) { return r.getBusinessJobByTextID(ctx, "desktop_publish", id, ` SELECT j.content_ref, NULL::jsonb, CASE WHEN j.compliance_blocked_reason IS NULL OR j.compliance_blocked_reason = '' THEN NULL ELSE to_jsonb(j.compliance_blocked_reason) END, jsonb_strip_nulls(jsonb_build_object( 'article_id', j.article_id, 'article_version_id', j.article_version_id, 'compliance_blocked_record_id', j.compliance_blocked_record_id, 'compliance_blocked_at', j.compliance_blocked_at, 'scheduled_at', j.scheduled_at )) FROM desktop_publish_jobs j WHERE j.desktop_id::text = $1`) } func (r *JobRepository) getDesktopTaskJob(ctx context.Context, id string) (*domain.JobDetail, error) { return r.getBusinessJobByTextID(ctx, "desktop_task", id, ` SELECT dt.payload, dt.result, dt.error, jsonb_strip_nulls(jsonb_build_object( 'job_id', dt.job_id, 'target_account_id', dt.target_account_id, 'target_client_id', dt.target_client_id, 'platform_id', dt.platform_id, 'dedup_key', dt.dedup_key, 'active_attempt_id', dt.active_attempt_id, 'lane', dt.lane, 'lane_weight', dt.lane_weight, 'source', dt.source, 'scheduler_group_key', dt.scheduler_group_key, 'monitor_task_id', dt.monitor_task_id, 'supersedes_task_id', dt.supersedes_task_id, 'control_flags', dt.control_flags, 'interrupt_generation', dt.interrupt_generation, 'interrupted_at', dt.interrupted_at, 'interrupt_reason', dt.interrupt_reason )) FROM desktop_tasks dt WHERE dt.desktop_id::text = $1`) } func (r *JobRepository) getComplianceReviewJob(ctx context.Context, id string) (*domain.JobDetail, error) { jobID, err := parseInt64ID(id) if err != nil { return nil, ErrJobNotFound } return r.getBusinessJob(ctx, "compliance_review", jobID, ` SELECT jsonb_strip_nulls(jsonb_build_object( 'message_id', crj.message_id, 'article_id', crj.article_id, 'article_version_id', crj.article_version_id, 'check_record_id', crj.check_record_id )), NULL::jsonb, CASE WHEN crj.last_error IS NULL OR crj.last_error = '' THEN NULL ELSE to_jsonb(crj.last_error) END, jsonb_strip_nulls(jsonb_build_object( 'available_at', crj.available_at, 'locked_at', crj.locked_at, 'locked_by', crj.locked_by )) FROM compliance_review_jobs crj WHERE crj.id = $1`, jobID) } func (r *JobRepository) getMonitoringCollectJob(ctx context.Context, id string) (*domain.JobDetail, error) { if r == nil || r.monitoringPool == nil { return nil, ErrJobNotFound } jobID, err := parseInt64ID(id) if err != nil { return nil, ErrJobNotFound } summary, err := r.getMonitoringSummary(ctx, jobID) if err != nil { return nil, err } var payloadRaw, metaRaw []byte err = r.monitoringPool.QueryRow(ctx, ` SELECT request_payload_json, jsonb_strip_nulls(jsonb_build_object( 'brand_id', brand_id, 'question_id', question_id, 'question_hash', encode(question_hash, 'hex'), 'ai_platform_id', ai_platform_id, 'collector_type', collector_type, 'trigger_source', trigger_source, 'run_mode', run_mode, 'business_date', business_date, 'planned_at', planned_at, 'lease_token_hash', lease_token_hash, 'leased_to_executor', leased_to_executor, 'callback_received_at', callback_received_at, 'skip_reason', skip_reason, 'dispatch_priority', dispatch_priority, 'dispatch_lane', dispatch_lane, 'target_client_id', target_client_id, 'dispatch_after', dispatch_after, 'interrupt_generation', interrupt_generation, 'superseded_by_request_id', superseded_by_request_id, 'last_dispatched_at', last_dispatched_at, 'dispatch_attempts', dispatch_attempts, 'ingest_shard_key', ingest_shard_key, 'execution_owner', execution_owner )) FROM monitoring_collect_tasks WHERE id = $1 `, jobID).Scan(&payloadRaw, &metaRaw) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrJobNotFound } if isUndefinedMonitoringRelation(err) { return nil, ErrJobNotFound } return nil, err } return &domain.JobDetail{ JobSummary: *summary, Payload: decodeJSONValue(payloadRaw), Metadata: decodeJSONMap(metaRaw), }, nil } func (r *JobRepository) getBusinessJob(ctx context.Context, source string, numericID int64, detailSQL string, args ...any) (*domain.JobDetail, error) { summary, err := r.getBusinessSummary(ctx, source, strconv.FormatInt(numericID, 10)) if err != nil { return nil, err } return r.attachBusinessDetail(ctx, summary, detailSQL, args...) } func (r *JobRepository) getBusinessJobByTextID(ctx context.Context, source, id, detailSQL string) (*domain.JobDetail, error) { summary, err := r.getBusinessSummary(ctx, source, id) if err != nil { return nil, err } return r.attachBusinessDetail(ctx, summary, detailSQL, id) } func (r *JobRepository) getBusinessSummary(ctx context.Context, source, id string) (*domain.JobSummary, error) { items, err := r.listBusinessJobs(ctx, domain.JobFilter{ Source: source, Keyword: id, Limit: 2000, }) if err != nil { return nil, err } for i := range items { if items[i].Source == source && items[i].ID == id { return &items[i], nil } } return nil, ErrJobNotFound } func (r *JobRepository) getMonitoringSummary(ctx context.Context, id int64) (*domain.JobSummary, error) { items, err := r.listMonitoringJobs(ctx, domain.JobFilter{ Source: "monitoring_collect", Keyword: strconv.FormatInt(id, 10), Limit: 2000, }) if err != nil { return nil, err } for i := range items { if items[i].ID == strconv.FormatInt(id, 10) { return &items[i], nil } } return nil, ErrJobNotFound } func (r *JobRepository) attachBusinessDetail(ctx context.Context, summary *domain.JobSummary, query string, args ...any) (*domain.JobDetail, error) { if summary == nil { return nil, ErrJobNotFound } var payloadRaw, resultRaw, errorRaw, metaRaw []byte err := r.pool.QueryRow(ctx, query, args...).Scan(&payloadRaw, &resultRaw, &errorRaw, &metaRaw) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrJobNotFound } return nil, err } return &domain.JobDetail{ JobSummary: *summary, Payload: decodeJSONValue(payloadRaw), Result: decodeJSONValue(resultRaw), Error: decodeJSONValue(errorRaw), Metadata: decodeJSONMap(metaRaw), }, nil } func (r *JobRepository) retryGenerationJob(ctx context.Context, id string) (*domain.JobDetail, error) { jobID, err := parseInt64ID(id) if err != nil { return nil, ErrJobNotFound } cmd, err := r.pool.Exec(ctx, ` UPDATE generation_tasks SET status = 'queued', error_message = NULL, started_at = NULL, completed_at = NULL, lease_token = NULL, lease_owner = NULL, lease_expires_at = NULL, updated_at = NOW() WHERE id = $1 AND status = 'failed' `, jobID) if err != nil { return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } return r.getGenerationJob(ctx, id) } func (r *JobRepository) retryTemplateAssistJob(ctx context.Context, id string) (*domain.JobDetail, error) { cmd, err := r.pool.Exec(ctx, ` UPDATE template_assist_tasks SET status = 'queued', result_json = NULL, error_message = NULL, started_at = NULL, completed_at = NULL, updated_at = NOW() WHERE id = $1 AND status = 'failed' `, strings.TrimSpace(id)) if err != nil { return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } return r.getTemplateAssistJob(ctx, id) } func (r *JobRepository) retryKolAssistJob(ctx context.Context, id string) (*domain.JobDetail, error) { cmd, err := r.pool.Exec(ctx, ` UPDATE kol_assist_tasks SET status = 'queued', result_json = NULL, error_message = NULL, started_at = NULL, completed_at = NULL, updated_at = NOW() WHERE id = $1 AND status = 'failed' `, strings.TrimSpace(id)) if err != nil { return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } return r.getKolAssistJob(ctx, id) } func (r *JobRepository) retryKnowledgeParseJob(ctx context.Context, id string) (*domain.JobDetail, error) { jobID, err := parseInt64ID(id) if err != nil { return nil, ErrJobNotFound } cmd, err := r.pool.Exec(ctx, ` WITH target AS ( SELECT kpt.id, kpt.tenant_id, kpt.knowledge_item_id FROM knowledge_parse_tasks kpt WHERE kpt.id = $1 AND kpt.status = 'failed' ), task_update AS ( UPDATE knowledge_parse_tasks kpt SET status = 'pending', error_message = NULL, started_at = NULL, completed_at = NULL, updated_at = NOW() FROM target WHERE kpt.id = target.id RETURNING target.tenant_id, target.knowledge_item_id ) UPDATE knowledge_items ki SET status = 'pending', error_message = NULL, updated_at = NOW() FROM task_update WHERE ki.id = task_update.knowledge_item_id AND ki.tenant_id = task_update.tenant_id `, jobID) if err != nil { return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } return r.getKnowledgeParseJob(ctx, id) } func (r *JobRepository) retryDesktopTaskJob(ctx context.Context, id string) (*domain.JobDetail, error) { errorJSON, _ := json.Marshal(map[string]any{ "message": "retried from ops job console", "retried_at": time.Now().UTC().Format(time.RFC3339), }) cmd, err := r.pool.Exec(ctx, ` UPDATE desktop_tasks SET status = 'queued', error = $2, result = NULL, active_attempt_id = NULL, lease_token_hash = NULL, lease_expires_at = NULL, attempts = attempts + 1, started_at = NULL, interrupted_at = NULL, interrupt_reason = NULL, enqueued_at = NOW(), updated_at = NOW() WHERE desktop_id::text = $1 AND status IN ('failed', 'unknown', 'aborted') `, strings.TrimSpace(id), errorJSON) if err != nil { return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } return r.getDesktopTaskJob(ctx, id) } func (r *JobRepository) retryComplianceReviewJob(ctx context.Context, id string) (*domain.JobDetail, error) { jobID, err := parseInt64ID(id) if err != nil { return nil, ErrJobNotFound } cmd, err := r.pool.Exec(ctx, ` UPDATE compliance_review_jobs SET status = 'pending', available_at = NOW(), locked_at = NULL, locked_by = NULL, last_error = NULL, updated_at = NOW() WHERE id = $1 AND status = 'failed' `, jobID) if err != nil { return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } return r.getComplianceReviewJob(ctx, id) } func (r *JobRepository) retryMonitoringCollectJob(ctx context.Context, id string) (*domain.JobDetail, error) { if r == nil || r.monitoringPool == nil { return nil, ErrJobNotFound } jobID, err := parseInt64ID(id) if err != nil { return nil, ErrJobNotFound } cmd, err := r.monitoringPool.Exec(ctx, ` UPDATE monitoring_collect_tasks SET status = 'pending', lease_token_hash = NULL, leased_to_executor = NULL, leased_at = NULL, lease_expires_at = NULL, callback_received_at = NULL, completed_at = NULL, skip_reason = NULL, error_message = NULL, dispatch_after = NOW(), updated_at = NOW() WHERE id = $1 AND status IN ('failed', 'expired', 'skipped') `, jobID) if err != nil { if isUndefinedMonitoringRelation(err) { return nil, ErrJobNotFound } return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } return r.getMonitoringCollectJob(ctx, id) } func (r *JobRepository) cancelGenerationJob(ctx context.Context, id, reason string) (*domain.JobDetail, error) { jobID, err := parseInt64ID(id) if err != nil { return nil, ErrJobNotFound } cmd, err := r.pool.Exec(ctx, ` UPDATE generation_tasks SET status = 'failed', error_message = $2, completed_at = NOW(), lease_token = NULL, lease_owner = NULL, lease_expires_at = NULL, updated_at = NOW() WHERE id = $1 AND status IN ('queued', 'running') `, jobID, reason) if err != nil { return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } return r.getGenerationJob(ctx, id) } func (r *JobRepository) cancelTemplateAssistJob(ctx context.Context, id, reason string) (*domain.JobDetail, error) { cmd, err := r.pool.Exec(ctx, ` UPDATE template_assist_tasks SET status = 'failed', error_message = $2, completed_at = NOW(), updated_at = NOW() WHERE id = $1 AND status IN ('queued', 'running') `, strings.TrimSpace(id), reason) if err != nil { return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } return r.getTemplateAssistJob(ctx, id) } func (r *JobRepository) cancelKolAssistJob(ctx context.Context, id, reason string) (*domain.JobDetail, error) { cmd, err := r.pool.Exec(ctx, ` UPDATE kol_assist_tasks SET status = 'failed', error_message = $2, completed_at = NOW(), updated_at = NOW() WHERE id = $1 AND status IN ('queued', 'running') `, strings.TrimSpace(id), reason) if err != nil { return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } return r.getKolAssistJob(ctx, id) } func (r *JobRepository) cancelKnowledgeParseJob(ctx context.Context, id, reason string) (*domain.JobDetail, error) { jobID, err := parseInt64ID(id) if err != nil { return nil, ErrJobNotFound } cmd, err := r.pool.Exec(ctx, ` WITH target AS ( SELECT kpt.id, kpt.tenant_id, kpt.knowledge_item_id FROM knowledge_parse_tasks kpt WHERE kpt.id = $1 AND kpt.status IN ('pending', 'running') ), task_update AS ( UPDATE knowledge_parse_tasks kpt SET status = 'deleted', error_message = $2, completed_at = NOW(), updated_at = NOW() FROM target WHERE kpt.id = target.id RETURNING target.tenant_id, target.knowledge_item_id ) UPDATE knowledge_items ki SET status = 'failed', error_message = $2, updated_at = NOW() FROM task_update WHERE ki.id = task_update.knowledge_item_id AND ki.tenant_id = task_update.tenant_id `, jobID, reason) if err != nil { return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } return r.getKnowledgeParseJob(ctx, id) } func (r *JobRepository) cancelDesktopPublishJob(ctx context.Context, id, reason string) (*domain.JobDetail, error) { tx, err := r.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) cmd, err := tx.Exec(ctx, ` UPDATE desktop_publish_jobs SET status = 'aborted', compliance_blocked_reason = $2, updated_at = NOW() WHERE desktop_id::text = $1 AND status IN ('queued', 'blocked_by_compliance') `, strings.TrimSpace(id), reason) if err != nil { return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } errorJSON, _ := json.Marshal(map[string]any{"message": reason}) if _, err := tx.Exec(ctx, ` UPDATE desktop_tasks SET status = 'aborted', error = $2, active_attempt_id = NULL, lease_token_hash = NULL, lease_expires_at = NULL, interrupted_at = COALESCE(interrupted_at, NOW()), interrupt_reason = 'ops_cancel', updated_at = NOW() WHERE job_id::text = $1 AND status IN ('queued', 'in_progress', 'unknown') `, strings.TrimSpace(id), errorJSON); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return r.getDesktopPublishJob(ctx, id) } func (r *JobRepository) cancelDesktopTaskJob(ctx context.Context, id, reason string) (*domain.JobDetail, error) { errorJSON, _ := json.Marshal(map[string]any{"message": reason}) cmd, err := r.pool.Exec(ctx, ` UPDATE desktop_tasks SET status = 'aborted', error = $2, active_attempt_id = NULL, lease_token_hash = NULL, lease_expires_at = NULL, interrupted_at = COALESCE(interrupted_at, NOW()), interrupt_reason = 'ops_cancel', updated_at = NOW() WHERE desktop_id::text = $1 AND status IN ('queued', 'in_progress', 'unknown') `, strings.TrimSpace(id), errorJSON) if err != nil { return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } return r.getDesktopTaskJob(ctx, id) } func (r *JobRepository) cancelComplianceReviewJob(ctx context.Context, id, reason string) (*domain.JobDetail, error) { jobID, err := parseInt64ID(id) if err != nil { return nil, ErrJobNotFound } cmd, err := r.pool.Exec(ctx, ` UPDATE compliance_review_jobs SET status = 'failed', locked_at = NULL, locked_by = NULL, last_error = $2, updated_at = NOW() WHERE id = $1 AND status IN ('pending', 'running') `, jobID, reason) if err != nil { return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } return r.getComplianceReviewJob(ctx, id) } func (r *JobRepository) cancelMonitoringCollectJob(ctx context.Context, id, reason string) (*domain.JobDetail, error) { if r == nil || r.monitoringPool == nil { return nil, ErrJobNotFound } jobID, err := parseInt64ID(id) if err != nil { return nil, ErrJobNotFound } cmd, err := r.monitoringPool.Exec(ctx, ` UPDATE monitoring_collect_tasks SET status = 'skipped', skip_reason = 'ops_cancel', error_message = $2, lease_token_hash = NULL, leased_to_executor = NULL, leased_at = NULL, lease_expires_at = NULL, completed_at = NOW(), updated_at = NOW() WHERE id = $1 AND status IN ('pending', 'leased', 'received', 'expired') `, jobID, reason) if err != nil { if isUndefinedMonitoringRelation(err) { return nil, ErrJobNotFound } return nil, err } if cmd.RowsAffected() == 0 { return nil, ErrJobNotFound } return r.getMonitoringCollectJob(ctx, id) } type jobScanner interface { Scan(dest ...any) error } func scanJobSummary(row jobScanner) (*domain.JobSummary, error) { var ( item domain.JobSummary numericID pgtype.Int8 uuidValue pgtype.Text tenantID pgtype.Int8 tenantName pgtype.Text workspaceID pgtype.Int8 workspaceName pgtype.Text userID pgtype.Int8 userName pgtype.Text title pgtype.Text target pgtype.Text queue pgtype.Text worker pgtype.Text attempts pgtype.Int4 priority pgtype.Int4 errorMessage pgtype.Text startedAt pgtype.Timestamptz completedAt pgtype.Timestamptz leaseExpires pgtype.Timestamptz availableAt pgtype.Timestamptz heartbeatAt pgtype.Timestamptz ) if err := row.Scan( &item.Source, &item.ID, &numericID, &uuidValue, &item.Kind, &item.Status, &item.Phase, &tenantID, &tenantName, &workspaceID, &workspaceName, &userID, &userName, &title, &target, &queue, &worker, &attempts, &priority, &errorMessage, &item.CreatedAt, &item.UpdatedAt, &startedAt, &completedAt, &leaseExpires, &availableAt, &heartbeatAt, ); err != nil { return nil, err } item.UID = item.Source + ":" + item.ID item.NumericID = int64PtrFromPg(numericID) item.UUID = stringPtrFromPg(uuidValue) item.TenantID = int64PtrFromPg(tenantID) item.TenantName = stringPtrFromPg(tenantName) item.WorkspaceID = int64PtrFromPg(workspaceID) item.WorkspaceName = stringPtrFromPg(workspaceName) item.UserID = int64PtrFromPg(userID) item.UserName = stringPtrFromPg(userName) item.Title = stringPtrFromPg(title) item.Target = stringPtrFromPg(target) item.Queue = stringPtrFromPg(queue) item.Worker = stringPtrFromPg(worker) item.Attempts = intPtrFromPg(attempts) item.Priority = intPtrFromPg(priority) item.ErrorMessage = stringPtrFromPg(errorMessage) item.StartedAt = timePtrFromPg(startedAt) item.CompletedAt = timePtrFromPg(completedAt) item.LeaseExpiresAt = timePtrFromPg(leaseExpires) item.AvailableAt = timePtrFromPg(availableAt) item.LastHeartbeatAt = timePtrFromPg(heartbeatAt) item.DurationSeconds = durationSeconds(item.StartedAt, item.CompletedAt, item.CreatedAt) item.IsStuck = isJobStuck(item) item.CanRetry = canRetryJob(item) item.CanCancel = canCancelJob(item) return &item, nil } func sortJobs(items []domain.JobSummary) { for i := 1; i < len(items); i++ { current := items[i] j := i - 1 for j >= 0 && jobLess(current, items[j]) { items[j+1] = items[j] j-- } items[j+1] = current } } func jobLess(a, b domain.JobSummary) bool { if !a.CreatedAt.Equal(b.CreatedAt) { return a.CreatedAt.After(b.CreatedAt) } if a.Source != b.Source { return a.Source < b.Source } return a.ID > b.ID } func buildJobCounts(items []domain.JobSummary) domain.JobCounts { counts := domain.JobCounts{ Total: int64(len(items)), ByPhase: map[string]int64{}, BySource: map[string]int64{}, } for _, item := range items { counts.ByPhase[item.Phase]++ counts.BySource[item.Source]++ } return counts } func durationSeconds(startedAt, completedAt *time.Time, createdAt time.Time) *int64 { start := createdAt if startedAt != nil { start = *startedAt } end := time.Now().UTC() if completedAt != nil { end = *completedAt } seconds := int64(end.Sub(start).Seconds()) if seconds < 0 { seconds = 0 } return &seconds } func isJobStuck(item domain.JobSummary) bool { now := time.Now().UTC() if item.LeaseExpiresAt != nil && item.LeaseExpiresAt.Before(now) && (item.Phase == domain.JobPhaseRunning || item.Phase == domain.JobPhaseQueued) { return true } if item.Phase == domain.JobPhaseRunning && item.UpdatedAt.Before(now.Add(-30*time.Minute)) { return true } if item.Phase == domain.JobPhaseQueued && item.UpdatedAt.Before(now.Add(-2*time.Hour)) { return true } return false } func canRetryJob(item domain.JobSummary) bool { switch item.Source { case "desktop_publish": return false case "knowledge_parse": return false case "monitoring_collect": return item.Phase == domain.JobPhaseFailed || item.Phase == domain.JobPhaseCanceled default: return item.Phase == domain.JobPhaseFailed || item.Phase == domain.JobPhaseCanceled || item.Phase == domain.JobPhaseUnknown } } func canCancelJob(item domain.JobSummary) bool { return item.Phase == domain.JobPhaseQueued || item.Phase == domain.JobPhaseRunning || item.Phase == domain.JobPhaseBlocked || item.Phase == domain.JobPhaseUnknown } func normalizeJobSource(source string) string { source = strings.TrimSpace(source) if source == "desktop_publish_job" { return "desktop_publish" } if source == "publish_job" { return "desktop_publish" } return source } func nullableTextValue(value string) *string { value = strings.TrimSpace(value) if value == "" { return nil } return &value } func nullableKeyword(value string) *string { value = strings.TrimSpace(value) if value == "" { return nil } return &value } func parseInt64ID(id string) (int64, error) { parsed, err := strconv.ParseInt(strings.TrimSpace(id), 10, 64) if err != nil || parsed <= 0 { return 0, fmt.Errorf("invalid id") } return parsed, nil } func int64PtrFromPg(value pgtype.Int8) *int64 { if !value.Valid { return nil } v := value.Int64 return &v } func intPtrFromPg(value pgtype.Int4) *int { if !value.Valid { return nil } v := int(value.Int32) return &v } func stringPtrFromPg(value pgtype.Text) *string { if !value.Valid { return nil } v := strings.TrimSpace(value.String) if v == "" { return nil } return &v } func timePtrFromPg(value pgtype.Timestamptz) *time.Time { if !value.Valid { return nil } v := value.Time return &v } func decodeJSONValue(raw []byte) any { if len(raw) == 0 { return nil } var value any if err := json.Unmarshal(raw, &value); err != nil { return string(raw) } return redactValue(value) } func decodeJSONMap(raw []byte) map[string]any { value := decodeJSONValue(raw) if value == nil { return nil } if m, ok := value.(map[string]any); ok { return m } return map[string]any{"value": value} } func redactValue(value any) any { switch typed := value.(type) { case map[string]any: out := make(map[string]any, len(typed)) for key, nested := range typed { if isSensitiveKey(key) { out[key] = "[REDACTED]" continue } out[key] = redactValue(nested) } return out case []any: out := make([]any, len(typed)) for i := range typed { out[i] = redactValue(typed[i]) } return out default: return value } } func isSensitiveKey(key string) bool { normalized := strings.ToLower(strings.TrimSpace(key)) for _, marker := range []string{ "password", "passwd", "secret", "token", "cookie", "authorization", "credential", "session", "api_key", "apikey", "access_key", "refresh", "lease_token", } { if strings.Contains(normalized, marker) { return true } } return false } func isUndefinedMonitoringRelation(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && (pgErr.Code == "42P01" || pgErr.Code == "42703") }