Files
geo/server/internal/ops/repository/job.go
T
root 7b4d7ccf68 feat(ops): add job center for cross-source job operations
Provide a unified ops console for inspecting, retrying and cancelling
jobs across generation, template/kol assist, knowledge parse, desktop
publish/task, compliance review and monitoring collect sources. Wires
RabbitMQ for retry republish and consolidates the desktop_publish_jobs
columns into the base migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:56:39 +08:00

1746 lines
50 KiB
Go

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")
}