acf053faf3
背景:发布管理把"逐条核验"的仪式强加给每一份文档。实测一份文档要 9~11 次交互,
且有两处顺序陷阱(冻结后批准 → 建批 422;生成即固化 → 批准被永久钉住)。
P0-1 建批时冻结:
- CreateReleaseBatch 在未指定版本时,自己把当前实时计划冻结成一份**已确认**版本并绑定
(同一事务;同摘要复用最近版本,planned 或 confirmed 都可复用,因此同一份计划不堆版本)。
- 语义不变:批一旦建立,清单/摘要/验收轮次/目标库绑定全部不可变、回滚按它走;变的只是
"谁在什么时候按固化键"——从操作员的手动两步变成建批这一下。
- 效果:去掉「生成新版本」「确认冻结」两步;消灭"批准晚于冻结 → 422"这类顺序陷阱;
前端不再传版本 id,也不再有"所选知识不在冻结清单里"的阻断。
- 计划→清单项的映射抽成 ChangeSetItemsFromPlan 供两处共用,否则两处算出的摘要不同,
"同一计划复用同一版本"永远不命中。
P0-2 验收通过自动批准:
- 验收三项结论达发布门槛(knowledge_exists/chunks_present=passed,retrieval_hit 与门槛同
口径)时,由 worker 写入 reviewer_ref='system:auto' 的批准记录,替代"每份文档点一次批准"。
- 策略 TEST_AUTO_REVIEW_POLICY = off | internal(默认,仅对内)| all;值不认识拒绝启动。
只影响审核这一步:生产授权、双闸与发布前快照门禁不受影响。界面上只读展示当前策略。
- 安全边界(写死在审核 SQL 里):系统只允许写"第一条"审核——人工一旦表过态,机器不得用一条
更晚的 approved 把人工结论翻回去;人工随时可再审。集成测试去掉该约束即 FAIL(已实测)。
- 自动批准自身的任何失败都不影响验收结论,只上报。
顺带(P1-1 前半,已被 P0-1 取代的部分已删除):
- 写操作失败把服务端 {"error": ...} 的原因带出来,并对已知原因给出下一步(不再只给 HTTP 状态码)。
- 当前的"不在已冻结清单里"标注与就地修复按钮随 P0-1 一起移除(该失败模式已不存在)。
测试:bind_time_freeze_integration_test.go(绑定已确认版本/同计划复用/冻结后不可变/
动作不符仍 NotEligible)、auto_review_integration_test.go(系统只写第一条、人工可再审)、
validate_test.go(对内自动/对外不自动/策略关闭/验收未过不批/自身失败不影响验收)、
auto_review_test.go、config_test 及前端能力解析测试。
实测耗时:本机 Go 全量 26 包通过、前端 178 条通过。
826 lines
28 KiB
Go
826 lines
28 KiB
Go
package postgres
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
|
||
"knowledge-governance/internal/governance"
|
||
)
|
||
|
||
var (
|
||
ErrInvalidAcceptanceResult = errors.New("invalid acceptance result")
|
||
ErrInvalidErrorCode = errors.New("invalid test publication error code")
|
||
ErrPublicationNotUploadable = errors.New("test publication is not uploadable")
|
||
ErrPublicationNotRetryable = errors.New("test publication is not retryable")
|
||
ErrPublicationNotFound = errors.New("test publication not found")
|
||
ErrPublicationStateConflict = errors.New("test publication state conflict")
|
||
)
|
||
|
||
type PublicationForUpload struct {
|
||
ID string
|
||
SourceItemVersionID string
|
||
TestKBID string
|
||
KnowledgeID string
|
||
IngestState governance.IngestState
|
||
ProcessingState governance.ProcessingState
|
||
TargetScope governance.Visibility
|
||
ContentObjectID string
|
||
SHA256 string
|
||
SizeBytes int64
|
||
MIME string
|
||
ValidatedFormat string
|
||
LocalSpoolRef string
|
||
Title string
|
||
}
|
||
|
||
type PublicationForPoll struct {
|
||
ID string
|
||
TestKBID string
|
||
KnowledgeID string
|
||
ProcessingState governance.ProcessingState
|
||
PollSequence int
|
||
LastRemoteStatus string
|
||
LastRemoteUpdatedAt *time.Time
|
||
ProcessingStartedAt *time.Time
|
||
LastObservedAt *time.Time
|
||
LastProgressAt *time.Time
|
||
}
|
||
|
||
type PublicationForValidation struct {
|
||
ID string
|
||
TestKBID string
|
||
KnowledgeID string
|
||
// TargetScope 决定这条知识发到哪个生产库(对内/对外)。自动批准策略依赖它:
|
||
// 默认只自动批准"对内","对外"仍要人工。
|
||
TargetScope string
|
||
}
|
||
|
||
type LegacyVerificationCandidate struct {
|
||
ID string
|
||
TestKBID string
|
||
KnowledgeID string
|
||
}
|
||
|
||
const loadPublicationForUploadSQL = `
|
||
SELECT p.id, p.source_item_version_id, p.test_kb_id, p.knowledge_id,
|
||
p.ingest_state, p.processing_state, p.target_scope,
|
||
co.id, co.sha256, co.size_bytes, co.mime, co.validated_format,
|
||
co.local_spool_ref, si.title
|
||
FROM test_publications AS p
|
||
JOIN source_item_versions AS siv ON siv.id = p.source_item_version_id
|
||
JOIN content_objects AS co ON co.id = siv.content_object_id
|
||
JOIN source_items AS si ON si.id = siv.source_item_id
|
||
WHERE p.id = $1
|
||
AND p.ingest_state IN ('pending', 'uploading')
|
||
AND p.processing_state = 'pending'
|
||
AND p.knowledge_id = ''
|
||
AND siv.content_state = 'verified'
|
||
AND co.content_state = 'verified'
|
||
AND co.local_spool_ref <> ''
|
||
AND co.size_bytes > 0
|
||
`
|
||
|
||
const loadPublicationForPollSQL = `
|
||
SELECT id, test_kb_id, knowledge_id, processing_state, poll_sequence,
|
||
last_remote_status, last_remote_updated_at, processing_started_at,
|
||
last_observed_at, updated_at
|
||
FROM test_publications
|
||
WHERE id = $1
|
||
`
|
||
|
||
const loadPublicationForValidationSQL = `
|
||
SELECT id, test_kb_id, knowledge_id, target_scope
|
||
FROM test_publications
|
||
WHERE id = $1
|
||
AND ingest_state = 'uploaded'
|
||
AND processing_state = 'completed'
|
||
AND knowledge_id <> ''
|
||
`
|
||
|
||
const listLegacyVerificationCandidatesSQL = `
|
||
SELECT p.id, p.test_kb_id, p.knowledge_id
|
||
FROM test_publications p
|
||
JOIN source_item_versions siv ON siv.id = p.source_item_version_id
|
||
JOIN source_items si ON si.id = siv.source_item_id
|
||
JOIN source_connections sc ON sc.id = si.source_connection_id
|
||
WHERE p.knowledge_id <> ''
|
||
AND sc.source_type = 'pp'
|
||
ORDER BY p.id
|
||
`
|
||
|
||
const countTestPublicationsSQL = `
|
||
SELECT COUNT(*)
|
||
FROM test_publications
|
||
WHERE ($1 = '' OR processing_state = ANY(string_to_array($1, ','))) AND ($2 = '' OR target_scope = $2)
|
||
AND ($3::boolean IS NOT TRUE OR EXISTS (
|
||
SELECT 1 FROM source_item_versions siv
|
||
JOIN source_items si ON si.id = siv.source_item_id
|
||
WHERE siv.id = test_publications.source_item_version_id
|
||
AND test_publications.superseded_at IS NULL
|
||
AND test_publications.knowledge_id <> ''
|
||
AND test_publications.processing_state = 'completed'
|
||
AND test_publications.retrieval_state NOT IN ('disabling','disabled')
|
||
AND si.policy_state = 'blocked'))
|
||
`
|
||
|
||
const listTestPublicationsSQL = `
|
||
SELECT p.id, si.title, p.ingest_state, p.processing_state, p.retrieval_state,
|
||
p.target_scope, COALESCE(p.change_set_id, ''),
|
||
CASE WHEN EXISTS (
|
||
SELECT 1
|
||
FROM test_publications other
|
||
JOIN source_item_versions other_version ON other_version.id=other.source_item_version_id
|
||
WHERE other.id<>p.id AND other_version.source_item_id=siv.source_item_id
|
||
) THEN 'updated' ELSE 'added' END AS change_type,
|
||
-- Both flags compare against a LEFT-JOINed content object, so the raw
|
||
-- expression is NULL (not false) for a row whose artifact was never
|
||
-- fetched; a NULL here makes the whole list fail to scan into a Go bool
|
||
-- and 500s the endpoint. Fold NULL to false.
|
||
COALESCE((p.ingest_state='pending' AND p.processing_state IN ('pending','planned')
|
||
AND p.retrieval_state='unpublished' AND p.knowledge_id=''
|
||
AND co.content_state='verified' AND co.local_spool_ref<>'' AND co.size_bytes>0
|
||
AND NOT EXISTS (SELECT 1 FROM processing_confirmations confirmation WHERE confirmation.publication_id=p.id)
|
||
AND NOT EXISTS (SELECT 1 FROM jobs job WHERE job.type='test.upload' AND job.payload->>'publication_id'=p.id)), false) AS can_start,
|
||
p.retry_count, p.last_error,
|
||
COALESCE(((p.processing_state IN ('failed', 'stalled')
|
||
OR (p.ingest_state = 'failed' AND p.processing_state = 'pending'))
|
||
AND co.content_state = 'verified'
|
||
AND co.local_spool_ref <> ''), false) AS can_retry,
|
||
p.updated_at
|
||
FROM test_publications p
|
||
JOIN source_item_versions siv ON siv.id = p.source_item_version_id
|
||
JOIN source_items si ON si.id = siv.source_item_id
|
||
LEFT JOIN content_objects co ON co.id = siv.content_object_id
|
||
WHERE ($1 = '' OR p.processing_state = ANY(string_to_array($1, ','))) AND ($2 = '' OR p.target_scope = $2) AND p.superseded_at IS NULL
|
||
-- removal=required:被禁止导入但仍在测试库存活(口径与监控「需下架」车道一致)
|
||
AND ($5::boolean IS NOT TRUE OR (p.knowledge_id <> '' AND p.processing_state = 'completed'
|
||
AND p.retrieval_state NOT IN ('disabling','disabled') AND si.policy_state = 'blocked'))
|
||
ORDER BY p.updated_at DESC, p.id
|
||
LIMIT $3 OFFSET $4
|
||
`
|
||
|
||
const listAcceptanceResultsSQL = `
|
||
SELECT check_type, state, policy_version, evidence_summary, observed_at
|
||
FROM acceptance_results
|
||
WHERE publication_id = $1
|
||
ORDER BY observed_at DESC
|
||
`
|
||
|
||
const loadRetryStateSQL = `
|
||
SELECT p.processing_state, p.ingest_state,
|
||
EXISTS (
|
||
SELECT 1 FROM source_item_versions siv
|
||
JOIN content_objects co ON co.id = siv.content_object_id
|
||
WHERE siv.id = p.source_item_version_id
|
||
AND co.content_state = 'verified'
|
||
AND co.local_spool_ref <> ''
|
||
)
|
||
FROM test_publications p
|
||
WHERE p.id = $1
|
||
`
|
||
|
||
const retryPublicationSQL = `
|
||
WITH updated AS (
|
||
UPDATE test_publications
|
||
SET ingest_state = 'pending',
|
||
processing_state = 'pending',
|
||
knowledge_id = '',
|
||
poll_sequence = 0,
|
||
last_remote_status = '',
|
||
last_remote_updated_at = NULL,
|
||
last_observed_at = NULL,
|
||
processing_started_at = NULL,
|
||
completed_at = NULL,
|
||
stalled_at = NULL,
|
||
last_error = '',
|
||
retry_count = retry_count + 1,
|
||
updated_at = $2
|
||
WHERE id = $1
|
||
AND (
|
||
processing_state IN ('failed', 'stalled')
|
||
OR (ingest_state = 'failed' AND processing_state = 'pending')
|
||
)
|
||
RETURNING id, retry_count
|
||
)
|
||
INSERT INTO jobs (
|
||
id, type, payload, state, priority, available_at,
|
||
max_attempts, idempotency_key, created_at, updated_at
|
||
)
|
||
SELECT $3, 'test.upload', $4::jsonb, 'pending', 0, $2, 5,
|
||
'test-retry:' || updated.id || ':' || updated.retry_count, $2, $2
|
||
FROM updated
|
||
ON CONFLICT (idempotency_key) DO NOTHING
|
||
`
|
||
|
||
const markUploadingSQL = `
|
||
UPDATE test_publications
|
||
SET ingest_state = 'uploading',
|
||
last_error = '',
|
||
updated_at = $2
|
||
WHERE id = $1
|
||
AND ingest_state IN ('pending', 'uploading')
|
||
AND processing_state = 'pending'
|
||
AND knowledge_id = ''
|
||
`
|
||
|
||
const recordUploadAndSchedulePollSQL = `
|
||
WITH updated AS (
|
||
UPDATE test_publications
|
||
SET knowledge_id = $2,
|
||
ingest_state = 'uploaded',
|
||
processing_state = 'pending',
|
||
poll_sequence = 1,
|
||
last_remote_status = 'pending',
|
||
processing_started_at = COALESCE(processing_started_at, $3),
|
||
last_observed_at = $3,
|
||
last_error = '',
|
||
updated_at = $3
|
||
WHERE id = $1
|
||
AND processing_state = 'pending'
|
||
AND (
|
||
(ingest_state = 'uploading' AND knowledge_id = '' AND poll_sequence = 0)
|
||
OR (ingest_state = 'uploaded' AND knowledge_id = $2 AND poll_sequence = 1)
|
||
)
|
||
RETURNING poll_sequence, retry_count
|
||
), enqueued AS (
|
||
-- Orchestration identity is (publication, round, sequence): retry_count is
|
||
-- bumped by every manual retry, so poll/validate keys can never collide
|
||
-- with the consumed first round's completed rows (jobs.idempotency_key is
|
||
-- globally unique and ON CONFLICT DO NOTHING would silently swallow the
|
||
-- next round's schedules).
|
||
INSERT INTO jobs (
|
||
id, type, payload, state, priority, available_at,
|
||
max_attempts, idempotency_key, created_at, updated_at
|
||
)
|
||
SELECT 'job-' || substring(md5($1 || '#test.poll#' || updated.retry_count || '#1') from 1 for 32),
|
||
'test.poll', $4::jsonb, 'pending', $5, $6, $7,
|
||
'test-poll:' || $1 || ':' || updated.retry_count || ':1', $3, $3
|
||
FROM updated
|
||
ON CONFLICT (idempotency_key) DO NOTHING
|
||
)
|
||
SELECT poll_sequence FROM updated
|
||
`
|
||
|
||
const recordRemoteStatusAndScheduleNextPollSQL = `
|
||
WITH updated AS (
|
||
UPDATE test_publications
|
||
SET processing_state = $3::varchar,
|
||
last_remote_status = $3::varchar,
|
||
last_remote_updated_at = $4,
|
||
last_observed_at = $5,
|
||
updated_at = CASE
|
||
WHEN last_remote_status IS DISTINCT FROM $3::varchar
|
||
OR last_remote_updated_at IS DISTINCT FROM $4
|
||
THEN $5
|
||
ELSE updated_at
|
||
END,
|
||
processing_started_at = COALESCE(processing_started_at, $5),
|
||
poll_sequence = $2 + 1
|
||
WHERE id = $1
|
||
AND poll_sequence = $2
|
||
AND ingest_state = 'uploaded'
|
||
AND knowledge_id <> ''
|
||
AND processing_state IN ('pending', 'processing', 'finalizing')
|
||
RETURNING poll_sequence, retry_count
|
||
), enqueued AS (
|
||
INSERT INTO jobs (
|
||
id, type, payload, state, priority, available_at,
|
||
max_attempts, idempotency_key, created_at, updated_at
|
||
)
|
||
SELECT 'job-' || substring(md5($1 || '#test.poll#' || updated.retry_count || '#' || updated.poll_sequence) from 1 for 32),
|
||
'test.poll', $6::jsonb, 'pending', $7, $8, $9,
|
||
'test-poll:' || $1 || ':' || updated.retry_count || ':' || updated.poll_sequence, $5, $5
|
||
FROM updated
|
||
ON CONFLICT (idempotency_key) DO NOTHING
|
||
)
|
||
SELECT poll_sequence FROM updated
|
||
`
|
||
|
||
const recordCompletedAndScheduleValidationSQL = `
|
||
WITH updated AS (
|
||
UPDATE test_publications
|
||
SET processing_state = 'completed',
|
||
last_remote_status = 'completed',
|
||
last_remote_updated_at = $3,
|
||
last_observed_at = $4,
|
||
completed_at = $4,
|
||
parser_generation = $7,
|
||
index_generation = $8,
|
||
last_error = '',
|
||
updated_at = $4
|
||
WHERE id = $1
|
||
AND poll_sequence = $2
|
||
AND ingest_state = 'uploaded'
|
||
AND knowledge_id <> ''
|
||
AND processing_state IN ('pending', 'processing', 'finalizing')
|
||
RETURNING poll_sequence, retry_count
|
||
), enqueued AS (
|
||
INSERT INTO jobs (
|
||
id, type, payload, state, priority, available_at,
|
||
max_attempts, idempotency_key, created_at, updated_at
|
||
)
|
||
SELECT 'job-' || substring(md5($1 || '#test.validate#' || updated.retry_count || '#' || updated.poll_sequence) from 1 for 32),
|
||
'test.validate', $5::jsonb, 'pending', $6, $9, $10,
|
||
'test-validate:' || $1 || ':' || updated.retry_count || ':' || updated.poll_sequence, $4, $4
|
||
FROM updated
|
||
ON CONFLICT (idempotency_key) DO NOTHING
|
||
)
|
||
SELECT poll_sequence FROM updated
|
||
`
|
||
|
||
const recordProcessingFailureSQL = `
|
||
UPDATE test_publications
|
||
SET processing_state = 'failed',
|
||
last_remote_status = 'failed',
|
||
last_observed_at = $4,
|
||
last_error = $3,
|
||
updated_at = $4
|
||
WHERE id = $1
|
||
AND poll_sequence = $2
|
||
AND ingest_state = 'uploaded'
|
||
AND knowledge_id <> ''
|
||
AND processing_state IN ('pending', 'processing', 'finalizing')
|
||
`
|
||
|
||
const recordUploadFailureSQL = `
|
||
UPDATE test_publications
|
||
SET ingest_state = 'failed',
|
||
processing_state = 'failed',
|
||
last_error = $2,
|
||
updated_at = $3
|
||
WHERE id = $1
|
||
AND ingest_state IN ('pending', 'uploading')
|
||
AND processing_state = 'pending'
|
||
AND knowledge_id = ''
|
||
`
|
||
|
||
const markStalledSQL = `
|
||
UPDATE test_publications
|
||
SET processing_state = 'stalled',
|
||
last_observed_at = $4,
|
||
last_error = 'processing_stalled',
|
||
updated_at = $4
|
||
WHERE id = $1
|
||
AND poll_sequence = $2
|
||
AND ingest_state = 'uploaded'
|
||
AND knowledge_id <> ''
|
||
AND processing_state IN ('pending', 'processing', 'finalizing')
|
||
AND updated_at <= $3
|
||
`
|
||
|
||
const appendAcceptanceResultSQL = `
|
||
INSERT INTO acceptance_results (
|
||
id, publication_id, run_id, check_type, state,
|
||
policy_version, evidence_summary, observed_at
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
|
||
ON CONFLICT (id) DO NOTHING
|
||
`
|
||
|
||
func (s *Store) LoadPublicationForUpload(ctx context.Context, publicationID string) (PublicationForUpload, error) {
|
||
var candidate PublicationForUpload
|
||
var ingestState string
|
||
var processingState string
|
||
err := s.db.QueryRow(ctx, loadPublicationForUploadSQL, publicationID).Scan(
|
||
&candidate.ID,
|
||
&candidate.SourceItemVersionID,
|
||
&candidate.TestKBID,
|
||
&candidate.KnowledgeID,
|
||
&ingestState,
|
||
&processingState,
|
||
&candidate.TargetScope,
|
||
&candidate.ContentObjectID,
|
||
&candidate.SHA256,
|
||
&candidate.SizeBytes,
|
||
&candidate.MIME,
|
||
&candidate.ValidatedFormat,
|
||
&candidate.LocalSpoolRef,
|
||
&candidate.Title,
|
||
)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return PublicationForUpload{}, ErrPublicationNotUploadable
|
||
}
|
||
if err != nil {
|
||
return PublicationForUpload{}, fmt.Errorf("load test publication for upload: %w", err)
|
||
}
|
||
candidate.IngestState = governance.IngestState(ingestState)
|
||
candidate.ProcessingState = governance.ProcessingState(processingState)
|
||
return candidate, nil
|
||
}
|
||
|
||
func (s *Store) LoadPublicationForPoll(ctx context.Context, publicationID string) (PublicationForPoll, error) {
|
||
var publication PublicationForPoll
|
||
var processingState string
|
||
err := s.db.QueryRow(ctx, loadPublicationForPollSQL, publicationID).Scan(
|
||
&publication.ID,
|
||
&publication.TestKBID,
|
||
&publication.KnowledgeID,
|
||
&processingState,
|
||
&publication.PollSequence,
|
||
&publication.LastRemoteStatus,
|
||
&publication.LastRemoteUpdatedAt,
|
||
&publication.ProcessingStartedAt,
|
||
&publication.LastObservedAt,
|
||
&publication.LastProgressAt,
|
||
)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return PublicationForPoll{}, ErrPublicationNotFound
|
||
}
|
||
if err != nil {
|
||
return PublicationForPoll{}, fmt.Errorf("load test publication for poll: %w", err)
|
||
}
|
||
publication.ProcessingState = governance.ProcessingState(processingState)
|
||
return publication, nil
|
||
}
|
||
|
||
func (s *Store) LoadPublicationForValidation(ctx context.Context, publicationID string) (PublicationForValidation, error) {
|
||
var publication PublicationForValidation
|
||
err := s.db.QueryRow(ctx, loadPublicationForValidationSQL, publicationID).Scan(
|
||
&publication.ID,
|
||
&publication.TestKBID,
|
||
&publication.KnowledgeID,
|
||
&publication.TargetScope,
|
||
)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return PublicationForValidation{}, ErrPublicationStateConflict
|
||
}
|
||
if err != nil {
|
||
return PublicationForValidation{}, fmt.Errorf("load test publication for validation: %w", err)
|
||
}
|
||
return publication, nil
|
||
}
|
||
|
||
func (s *Store) ListLegacyVerificationCandidates(ctx context.Context) ([]LegacyVerificationCandidate, error) {
|
||
rows, err := s.db.Query(ctx, listLegacyVerificationCandidatesSQL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list legacy verification candidates: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var candidates []LegacyVerificationCandidate
|
||
for rows.Next() {
|
||
var candidate LegacyVerificationCandidate
|
||
if err := rows.Scan(&candidate.ID, &candidate.TestKBID, &candidate.KnowledgeID); err != nil {
|
||
return nil, fmt.Errorf("scan legacy verification candidate: %w", err)
|
||
}
|
||
candidates = append(candidates, candidate)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("iterate legacy verification candidates: %w", err)
|
||
}
|
||
return candidates, nil
|
||
}
|
||
|
||
func (s *Store) ListTestPublications(ctx context.Context, filter governance.TestPublicationFilter) (governance.TestPublicationPage, error) {
|
||
var page governance.TestPublicationPage
|
||
if filter.TargetScope != "" && filter.TargetScope != string(governance.VisibilityInternal) && filter.TargetScope != string(governance.VisibilityExternal) {
|
||
return governance.TestPublicationPage{}, errors.New("invalid test publication target scope")
|
||
}
|
||
if err := s.db.QueryRow(ctx, countTestPublicationsSQL, filter.ProcessingState, filter.TargetScope, filter.RemovalRequired).Scan(&page.Total); err != nil {
|
||
return governance.TestPublicationPage{}, fmt.Errorf("count test publications: %w", err)
|
||
}
|
||
rows, err := s.db.Query(ctx, listTestPublicationsSQL, filter.ProcessingState, filter.TargetScope, filter.Limit, filter.Offset, filter.RemovalRequired)
|
||
if err != nil {
|
||
return governance.TestPublicationPage{}, fmt.Errorf("list test publications: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
page.Items = make([]governance.TestPublication, 0)
|
||
for rows.Next() {
|
||
var item governance.TestPublication
|
||
if err := rows.Scan(
|
||
&item.ID,
|
||
&item.Title,
|
||
&item.IngestState,
|
||
&item.ProcessingState,
|
||
&item.RetrievalState,
|
||
&item.TargetScope,
|
||
&item.ChangeSetID,
|
||
&item.ChangeType,
|
||
&item.CanStart,
|
||
&item.RetryCount,
|
||
&item.LastError,
|
||
&item.CanRetry,
|
||
&item.UpdatedAt,
|
||
); err != nil {
|
||
return governance.TestPublicationPage{}, fmt.Errorf("scan test publication: %w", err)
|
||
}
|
||
page.Items = append(page.Items, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return governance.TestPublicationPage{}, fmt.Errorf("iterate test publications: %w", err)
|
||
}
|
||
return page, nil
|
||
}
|
||
|
||
func (s *Store) ListAcceptanceResults(ctx context.Context, publicationID string) ([]governance.AcceptanceRecord, error) {
|
||
rows, err := s.db.Query(ctx, listAcceptanceResultsSQL, publicationID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list acceptance results: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
results := make([]governance.AcceptanceRecord, 0)
|
||
for rows.Next() {
|
||
var record governance.AcceptanceRecord
|
||
if err := rows.Scan(&record.CheckType, &record.State, &record.PolicyVersion, &record.EvidenceSummary, &record.ObservedAt); err != nil {
|
||
return nil, fmt.Errorf("scan acceptance result: %w", err)
|
||
}
|
||
results = append(results, record)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("iterate acceptance results: %w", err)
|
||
}
|
||
return results, nil
|
||
}
|
||
|
||
func (s *Store) RetryPublicationWithOperation(ctx context.Context, publicationID string, now time.Time, operationID string) (bool, error) {
|
||
fingerprint, err := operationFingerprint(struct {
|
||
PublicationID string `json:"publication_id"`
|
||
}{publicationID})
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
raw, replayed, err := s.executeOperation(ctx, "test.publication.retry", operationID, fingerprint, now, func(tx pgx.Tx) (json.RawMessage, error) {
|
||
if err := NewStore(tx).RetryPublication(ctx, publicationID, now); err != nil {
|
||
return nil, err
|
||
}
|
||
return json.Marshal(map[string]string{"id": publicationID, "status": "retry_queued"})
|
||
})
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if !json.Valid(raw) {
|
||
return false, errors.New("invalid stored retry result")
|
||
}
|
||
return replayed, nil
|
||
}
|
||
|
||
func (s *Store) RetryPublication(ctx context.Context, publicationID string, now time.Time) error {
|
||
var processingState, ingestState string
|
||
var spoolAvailable bool
|
||
err := s.db.QueryRow(ctx, loadRetryStateSQL, publicationID).Scan(&processingState, &ingestState, &spoolAvailable)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrPublicationNotFound
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("load test publication retry state: %w", err)
|
||
}
|
||
legacyUploadFailed := ingestState == string(governance.IngestFailed) && processingState == string(governance.ProcessingPending)
|
||
if processingState != string(governance.ProcessingFailed) && processingState != string(governance.ProcessingStalled) && !legacyUploadFailed {
|
||
return ErrPublicationStateConflict
|
||
}
|
||
if !spoolAvailable {
|
||
return ErrPublicationNotRetryable
|
||
}
|
||
payload, err := json.Marshal(struct {
|
||
PublicationID string `json:"publication_id"`
|
||
}{PublicationID: publicationID})
|
||
if err != nil {
|
||
return errors.New("encode retry upload job")
|
||
}
|
||
jobID := orchestrationJobID("test.upload", publicationID, fmt.Sprint(now.UnixNano()))
|
||
tag, err := s.db.Exec(ctx, retryPublicationSQL, publicationID, now, jobID, payload)
|
||
if err != nil {
|
||
return fmt.Errorf("retry test publication: %w", err)
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrPublicationStateConflict
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) MarkUploading(ctx context.Context, publicationID string, now time.Time) error {
|
||
tag, err := s.db.Exec(ctx, markUploadingSQL, publicationID, now)
|
||
if err != nil {
|
||
return fmt.Errorf("mark test publication uploading: %w", err)
|
||
}
|
||
if tag.RowsAffected() != 1 {
|
||
return ErrPublicationStateConflict
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) RecordUploadAndSchedulePoll(ctx context.Context, publicationID, knowledgeID string, now, nextAt time.Time) (int, error) {
|
||
payload, err := json.Marshal(struct {
|
||
PublicationID string `json:"publication_id"`
|
||
Sequence int `json:"sequence"`
|
||
}{PublicationID: publicationID, Sequence: 1})
|
||
if err != nil {
|
||
return 0, errors.New("encode first test poll job")
|
||
}
|
||
var sequence int
|
||
err = s.db.QueryRow(
|
||
ctx,
|
||
recordUploadAndSchedulePollSQL,
|
||
publicationID,
|
||
knowledgeID,
|
||
now,
|
||
payload,
|
||
0,
|
||
nextAt,
|
||
5,
|
||
).Scan(&sequence)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return 0, ErrPublicationStateConflict
|
||
}
|
||
if err != nil {
|
||
return 0, fmt.Errorf("record test upload and schedule poll: %w", err)
|
||
}
|
||
return sequence, nil
|
||
}
|
||
|
||
func (s *Store) RecordRemoteStatusAndScheduleNextPoll(
|
||
ctx context.Context,
|
||
publicationID string,
|
||
sequence int,
|
||
status governance.ProcessingState,
|
||
remoteUpdatedAt *time.Time,
|
||
observedAt time.Time,
|
||
nextAt time.Time,
|
||
) (int, error) {
|
||
if sequence < 1 || (status != governance.ProcessingPending && status != governance.ProcessingProcessing && status != governance.ProcessingFinalizing) {
|
||
return 0, ErrPublicationStateConflict
|
||
}
|
||
nextSequence := sequence + 1
|
||
payload, err := json.Marshal(struct {
|
||
PublicationID string `json:"publication_id"`
|
||
Sequence int `json:"sequence"`
|
||
}{PublicationID: publicationID, Sequence: nextSequence})
|
||
if err != nil {
|
||
return 0, errors.New("encode next test poll job")
|
||
}
|
||
var storedSequence int
|
||
err = s.db.QueryRow(
|
||
ctx,
|
||
recordRemoteStatusAndScheduleNextPollSQL,
|
||
publicationID,
|
||
sequence,
|
||
string(status),
|
||
remoteUpdatedAt,
|
||
observedAt,
|
||
payload,
|
||
0,
|
||
nextAt,
|
||
5,
|
||
).Scan(&storedSequence)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return 0, ErrPublicationStateConflict
|
||
}
|
||
if err != nil {
|
||
return 0, fmt.Errorf("record remote status and schedule next test poll: %w", err)
|
||
}
|
||
return storedSequence, nil
|
||
}
|
||
|
||
func (s *Store) RecordCompletedAndScheduleValidation(
|
||
ctx context.Context,
|
||
publicationID string,
|
||
sequence int,
|
||
remoteUpdatedAt *time.Time,
|
||
observedAt time.Time,
|
||
) error {
|
||
if sequence < 1 {
|
||
return ErrPublicationStateConflict
|
||
}
|
||
// The release gate requires a parse revision for every completed
|
||
// publication. WeKnora reports a single terminal update timestamp for the
|
||
// parse+index pipeline, so both generations are stamped from it (falling
|
||
// back to the observation time) together with the pinned WeKnora contract
|
||
// identity. This keeps manual/seeded publications releasable without
|
||
// pretending the document was parsed by a different pipeline.
|
||
generationAt := observedAt.UTC()
|
||
if remoteUpdatedAt != nil && !remoteUpdatedAt.IsZero() {
|
||
generationAt = remoteUpdatedAt.UTC()
|
||
}
|
||
generation := "weknora-v0.7.1-m74@" + generationAt.Format(time.RFC3339)
|
||
payload, err := json.Marshal(struct {
|
||
PublicationID string `json:"publication_id"`
|
||
}{PublicationID: publicationID})
|
||
if err != nil {
|
||
return errors.New("encode test validation job")
|
||
}
|
||
var storedSequence int
|
||
err = s.db.QueryRow(
|
||
ctx,
|
||
recordCompletedAndScheduleValidationSQL,
|
||
publicationID,
|
||
sequence,
|
||
remoteUpdatedAt,
|
||
observedAt,
|
||
payload,
|
||
0,
|
||
generation,
|
||
generation,
|
||
observedAt,
|
||
5,
|
||
).Scan(&storedSequence)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrPublicationStateConflict
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("record test completion and schedule validation: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) RecordProcessingFailure(ctx context.Context, publicationID string, sequence int, code string, observedAt time.Time) error {
|
||
if sequence < 1 || !validErrorCode(code) {
|
||
return ErrInvalidErrorCode
|
||
}
|
||
tag, err := s.db.Exec(ctx, recordProcessingFailureSQL, publicationID, sequence, code, observedAt)
|
||
if err != nil {
|
||
return fmt.Errorf("record test processing failure: %w", err)
|
||
}
|
||
if tag.RowsAffected() != 1 {
|
||
return ErrPublicationStateConflict
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) RecordUploadFailure(ctx context.Context, publicationID, code string, observedAt time.Time) error {
|
||
if !validErrorCode(code) {
|
||
return ErrInvalidErrorCode
|
||
}
|
||
tag, err := s.db.Exec(ctx, recordUploadFailureSQL, publicationID, code, observedAt)
|
||
if err != nil {
|
||
return fmt.Errorf("record test upload failure: %w", err)
|
||
}
|
||
if tag.RowsAffected() != 1 {
|
||
return ErrPublicationStateConflict
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) MarkStalled(ctx context.Context, publicationID string, sequence int, cutoff, observedAt time.Time) error {
|
||
if sequence < 1 || cutoff.After(observedAt) {
|
||
return ErrPublicationStateConflict
|
||
}
|
||
tag, err := s.db.Exec(ctx, markStalledSQL, publicationID, sequence, cutoff, observedAt)
|
||
if err != nil {
|
||
return fmt.Errorf("mark test publication stalled: %w", err)
|
||
}
|
||
if tag.RowsAffected() != 1 {
|
||
return ErrPublicationStateConflict
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) AppendAcceptanceResult(ctx context.Context, result governance.AcceptanceResult) error {
|
||
if !validShortID(result.ID) || !validShortID(result.PublicationID) || !validShortID(result.RunID) ||
|
||
!validErrorCode(result.CheckType) || !validErrorCode(result.PolicyVersion) || !result.State.Valid() ||
|
||
result.ObservedAt.IsZero() || len(result.EvidenceSummary) == 0 || len(result.EvidenceSummary) > 4096 || !json.Valid(result.EvidenceSummary) {
|
||
return ErrInvalidAcceptanceResult
|
||
}
|
||
var evidence map[string]any
|
||
if err := json.Unmarshal(result.EvidenceSummary, &evidence); err != nil || evidence == nil {
|
||
return ErrInvalidAcceptanceResult
|
||
}
|
||
_, err := s.db.Exec(
|
||
ctx,
|
||
appendAcceptanceResultSQL,
|
||
result.ID,
|
||
result.PublicationID,
|
||
result.RunID,
|
||
result.CheckType,
|
||
string(result.State),
|
||
result.PolicyVersion,
|
||
[]byte(result.EvidenceSummary),
|
||
result.ObservedAt,
|
||
)
|
||
if err != nil {
|
||
return fmt.Errorf("append acceptance result: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validShortID(value string) bool {
|
||
return len(value) > 0 && len(value) <= 36 && !strings.ContainsAny(value, " /\\ \r\n")
|
||
}
|
||
|
||
func validErrorCode(code string) bool {
|
||
if len(code) == 0 || len(code) > 64 {
|
||
return false
|
||
}
|
||
for _, character := range code {
|
||
if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || strings.ContainsRune("_.:-", character) {
|
||
continue
|
||
}
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func orchestrationJobID(parts ...string) string {
|
||
digest := sha256.New()
|
||
for _, part := range parts {
|
||
_, _ = digest.Write([]byte{0})
|
||
_, _ = digest.Write([]byte(part))
|
||
}
|
||
return "job-" + hex.EncodeToString(digest.Sum(nil))[:32]
|
||
}
|