Files
knowledge-governance/internal/httpapi/library_flow.go
T
姚章浩 2038462dbb
build / build (push) Successful in 1m24s
feat(library): Wiki 页面级差异与页面展示
为什么:撤销文档后 Wiki 变化此前只有"快照保存"证据;页面级差异能给出增/改/撤,
但必须写明它不等于实体、概念、边与引用的语义重算。

- overview 差异新增 wiki 字段:读两份快照的 wiki_pages 行数组比较,缺部件时 unknown。
- 发布管理"本次变更"显示 Wiki 新增/更新/撤销页数与页面级声明。
- 真实归档验证:对内 vs 对外 = 新增 31 / 撤销 1,反向镜像一致。
2026-10-28 15:15:33 +08:00

589 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
"knowledge-governance/internal/librarydiff"
"knowledge-governance/internal/librarysnapshot"
)
// 整库版本/状态/差异读取契约 + 生产同步的 fail-closed 入口。
//
// 三类版本严格分开,不能互相顶替:
// - 测试封存(archive 里的 sealed 产物)——只证明测试库有一份完整版本;
// - 生产当前生效(library_versions.kind=production_effective)——必须有生产验证证据;
// - 生产预备份(kind=production_prebackup)——每次生产写入前的独立备份。
// 生产未建立权威版本时返回 unknown,不拿最新测试快照冒充。
type libraryVersionView struct {
ID string `json:"id"`
Kind string `json:"kind"`
SnapshotID string `json:"snapshot_id"`
VersionLabel string `json:"version_label"`
SourceJobID string `json:"source_job_id"`
Evidence map[string]any `json:"evidence"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
SupersededAt *time.Time `json:"superseded_at,omitempty"`
}
// librarySyncJobType 与 worker 的处理器类型一致;单独定义避免 API 依赖 worker 包。
const librarySyncJobType = "library.sync"
type libraryKnownView struct {
State string `json:"state"` // known | unknown
Version *libraryVersionView `json:"version,omitempty"`
Reason string `json:"reason,omitempty"`
}
type libraryDiffView struct {
State string `json:"state"` // unknown | computed
Reason string `json:"reason,omitempty"`
Added []any `json:"added"`
Updated []any `json:"updated"`
Removed []any `json:"removed"`
// BaseSnapshot/TargetSnapshot 标明这次比对用的是哪两个整库版本,
// 避免把某次历史差异当成"当前拟发布"。
BaseSnapshot string `json:"base_snapshot,omitempty"`
TargetSnapshot string `json:"target_snapshot,omitempty"`
// Wiki 是**页面级**差异:只能证明页面被增/改/撤,不能证明共享正文、实体、
// 概念或边被重新计算——后者需要 WeKnora 内部语义,本平台不做也不声称。
Wiki *libraryWikiDiffView `json:"wiki,omitempty"`
}
type libraryWikiDiffView struct {
State string `json:"state"` // computed | unknown
Reason string `json:"reason,omitempty"`
Added int `json:"added"`
Updated int `json:"updated"`
Removed int `json:"removed"`
// Sample 只列前若干页,避免把整库 Wiki 列表塞进接口。
Sample []string `json:"sample"`
Note string `json:"note"`
}
// wikiDiffOf 比较两份快照的 wiki_pages 行数组(页面级)。
// 它不能替代"删除文档后共享正文/实体/边/引用如何变化"的语义证据。
func wikiDiffOf(archive *librarysnapshot.Archive, baseID, targetID string) *libraryWikiDiffView {
unknown := func(reason string) *libraryWikiDiffView {
return &libraryWikiDiffView{State: "unknown", Reason: reason, Sample: []string{},
Note: "页面级比对缺失时不能声称 Wiki 已同步"}
}
rows := func(id string) ([]map[string]any, string) {
manifest, err := archive.Load(id)
if err != nil {
return nil, "快照不可读"
}
for _, part := range manifest.Parts {
if part.Kind != "wiki" || part.Name != "wiki_pages" {
continue
}
reader, err := archive.OpenPart(part)
if err != nil {
return nil, "Wiki 正文不可读"
}
defer reader.Close()
parsed, err := librarydiff.ReadDocuments(reader)
if err != nil {
return nil, "Wiki 行格式不符"
}
return parsed, ""
}
return nil, "快照里没有 wiki_pages 部件"
}
base, reason := rows(baseID)
if reason != "" {
return unknown("生产版本:" + reason)
}
target, reason := rows(targetID)
if reason != "" {
return unknown("测试版本:" + reason)
}
result, err := librarydiff.Compare(base, target)
if err != nil {
return unknown("Wiki 页面比对失败:" + err.Error())
}
view := &libraryWikiDiffView{State: "computed", Added: len(result.Added), Updated: len(result.Updated),
Removed: len(result.Removed), Sample: []string{},
Note: "页面级差异:只说明页面被增/改/撤,不代表实体、概念、边或引用已重算"}
for _, item := range result.Added {
if len(view.Sample) >= 20 {
break
}
view.Sample = append(view.Sample, "新增 "+item.Title)
}
for _, item := range result.Updated {
if len(view.Sample) >= 20 {
break
}
view.Sample = append(view.Sample, "更新 "+item.Title)
}
for _, item := range result.Removed {
if len(view.Sample) >= 20 {
break
}
view.Sample = append(view.Sample, "撤销 "+item.Title)
}
return view
}
// computeLibraryDiff 用归档里的 knowledges 行数组比较两个快照:
// 身份是文档 ID,内容是排除易变列后的整行摘要;任一侧读不到就返回 false,
// 由调用方保持 unknown 并写明原因,而不是给出空差异冒充"无变化"。
func computeLibraryDiff(root, baseID, targetID string) (libraryDiffView, bool) {
unknown := libraryDiffView{State: "unknown", Added: []any{}, Updated: []any{}, Removed: []any{},
BaseSnapshot: baseID, TargetSnapshot: targetID}
if root == "" {
unknown.Reason = "整库归档未配置,无法比较生产与测试版本"
return unknown, false
}
if baseID == "" || targetID == "" {
unknown.Reason = "生产当前版本或测试封存缺失,没有可比的两份整库版本"
return unknown, false
}
archive, err := librarysnapshot.Open(root, 256*1024*1024)
if err != nil {
unknown.Reason = "归档根不可用,无法比较"
return unknown, false
}
defer archive.Close()
read := func(id string) ([]map[string]any, string) {
manifest, loadErr := archive.Load(id)
if loadErr != nil {
return nil, "快照不在本地归档或校验失败"
}
for _, part := range manifest.Parts {
if part.Kind != "database" || part.Name != "knowledges" {
continue
}
reader, openErr := archive.OpenPart(part)
if openErr != nil {
return nil, "归档正文不可读"
}
defer reader.Close()
rows, readErr := librarydiff.ReadDocuments(reader)
if readErr != nil {
return nil, "归档正文格式不符"
}
return rows, ""
}
return nil, "快照里没有 knowledges 部件"
}
base, reason := read(baseID)
if reason != "" {
unknown.Reason = "生产版本:" + reason
return unknown, false
}
target, reason := read(targetID)
if reason != "" {
unknown.Reason = "测试版本:" + reason
return unknown, false
}
result, err := librarydiff.Compare(base, target)
if err != nil {
unknown.Reason = "差异计算失败:" + err.Error()
return unknown, false
}
view := libraryDiffView{State: "computed", Added: []any{}, Updated: []any{}, Removed: []any{},
BaseSnapshot: baseID, TargetSnapshot: targetID}
view.Wiki = wikiDiffOf(archive, baseID, targetID)
for _, item := range result.Added {
view.Added = append(view.Added, item)
}
for _, item := range result.Updated {
view.Updated = append(view.Updated, item)
}
for _, item := range result.Removed {
view.Removed = append(view.Removed, item)
}
return view, true
}
// libraryAuthorizationView 让页面能直说"有没有授权、有效期到什么时候"。
type libraryAuthorizationView struct {
State string `json:"state"` // active | none
AuthorizedBy string `json:"authorized_by,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Reason string `json:"reason,omitempty"`
}
type libraryGateView struct {
SyncAllowed bool `json:"sync_allowed"`
Reasons []string `json:"reasons"`
}
type libraryOverview struct {
Scope string `json:"scope"`
TestSealed *librarySnapshotView `json:"test_sealed,omitempty"`
TestSealedReason string `json:"test_sealed_reason,omitempty"`
Production libraryKnownView `json:"production"`
Prebackup libraryKnownView `json:"prebackup"`
Diff libraryDiffView `json:"diff"`
Job *governance.LibraryJob `json:"job,omitempty"`
Jobs []governance.LibraryJob `json:"jobs"`
Gate libraryGateView `json:"gate"`
Authorization libraryAuthorizationView `json:"authorization"`
CaptureAvailable bool `json:"capture_available"`
CaptureReason string `json:"capture_reason,omitempty"`
Now time.Time `json:"now"`
}
type libraryJobStore interface {
CreateLibraryJob(ctx context.Context, job governance.LibraryJob, stages []string, now time.Time) (governance.LibraryJob, bool, error)
LibraryJob(ctx context.Context, id string) (governance.LibraryJob, error)
ListLibraryJobs(ctx context.Context, scope string, limit int) ([]governance.LibraryJob, error)
RecordLibraryVersion(ctx context.Context, version governance.LibraryVersion) error
LibraryVersions(ctx context.Context, scope string, limit int) ([]governance.LibraryVersion, error)
UpdateLibraryStage(ctx context.Context, jobID, stage, state string, total, completed, bytesTotal, bytesDone *int64, detail string, now time.Time) error
AdvanceLibraryJob(ctx context.Context, jobID string, state governance.LibraryJobState, stage, message, recovery, snapshotID, targetSnapshotID string, now time.Time) error
AttachLibraryQueueJob(ctx context.Context, jobID, queueJobID string, now time.Time) error
CancelLibraryJob(ctx context.Context, jobID, reason string, now time.Time) error
}
func libraryStore(config Config) (libraryJobStore, bool) {
store, ok := config.Store.(libraryJobStore)
return store, ok
}
// activeGrantFor 只查"目标+快照+同一参数摘要"的未过期未撤销授权。
func activeGrantFor(ctx context.Context, config Config, scope, snapshotID string) (governance.LibraryReleaseAuthorization, bool) {
if snapshotID == "" {
return governance.LibraryReleaseAuthorization{}, false
}
store, ok := config.Store.(interface {
ActiveLibraryAuthorization(ctx context.Context, scope, snapshotID, paramsDigest string, now time.Time) (governance.LibraryReleaseAuthorization, bool, error)
})
if !ok {
return governance.LibraryReleaseAuthorization{}, false
}
digest, err := librarySyncParamsDigest(scope, snapshotID)
if err != nil {
return governance.LibraryReleaseAuthorization{}, false
}
grant, found, err := store.ActiveLibraryAuthorization(ctx, scope, snapshotID, digest, time.Now().UTC())
if err != nil || !found {
return governance.LibraryReleaseAuthorization{}, false
}
return grant, true
}
func authorizationViewOf(grant governance.LibraryReleaseAuthorization, found bool) libraryAuthorizationView {
if !found {
return libraryAuthorizationView{State: "none", Reason: "没有与目标快照匹配的有效发布授权"}
}
expires := grant.ExpiresAt
return libraryAuthorizationView{State: "active", AuthorizedBy: grant.AuthorizedBy, ExpiresAt: &expires}
}
func scopeFromQuery(r *http.Request) (string, error) {
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
if scope == "" {
scope = "internal"
}
if scope != "internal" && scope != "external" {
return "", errors.New("scope must be internal or external")
}
return scope, nil
}
func libraryVersionViewOf(item governance.LibraryVersion) libraryVersionView {
return libraryVersionView{ID: item.ID, Kind: string(item.Kind), SnapshotID: item.SnapshotID,
VersionLabel: item.VersionLabel, SourceJobID: item.SourceJobID, Evidence: item.Evidence,
CreatedBy: item.CreatedBy, CreatedAt: item.CreatedAt, SupersededAt: item.SupersededAt}
}
// librarySyncGate 只依据真实存在的证据判断能不能写生产:闸口、生产端点、
// 生产权威版本、目标快照的预备份。任何一项缺失都给出具体原因,不默认放行。
func librarySyncGate(config Config, production libraryKnownView, prebackup libraryKnownView, target string) libraryGateView {
return librarySyncGateWithGrant(config, production, prebackup, target, false)
}
// librarySyncGateWithGrant 在原有证据之外再要求一份有效授权:
// 授权绑定了目标、快照、参数与有效期,缺它就不能写生产。
func librarySyncGateWithGrant(config Config, production libraryKnownView, prebackup libraryKnownView, target string, hasGrant bool) libraryGateView {
gate := libraryGateView{SyncAllowed: false, Reasons: []string{}}
if !hasGrant {
gate.Reasons = append(gate.Reasons, "没有与目标快照匹配的有效发布授权")
}
if !config.ProductionReleaseRequestsEnabled {
gate.Reasons = append(gate.Reasons, "生产写入闸口关闭(PRODUCTION_RELEASE_REQUESTS_ENABLED=false)")
}
if !config.ProductionBackupRestoreVerified {
gate.Reasons = append(gate.Reasons, "没有已核验的生产恢复演练证据(PRODUCTION_BACKUP_RESTORE_VERIFIED=false)")
}
if strings.TrimSpace(config.ProductionWeKnoraBaseURL) == "" {
gate.Reasons = append(gate.Reasons, "生产端点未配置,无法核验生产当前版本")
}
if production.State != "known" {
gate.Reasons = append(gate.Reasons, "生产当前权威版本未知,不能启动写入")
}
if target == "" {
gate.Reasons = append(gate.Reasons, "还没有可同步的测试封存版本")
}
if prebackup.State != "known" {
gate.Reasons = append(gate.Reasons, "本次目标快照还没有生产预备份")
}
if config.LibraryCapacityBudgetBytes <= 0 {
gate.Reasons = append(gate.Reasons, "容量预算未设置(LIBRARY_CAPACITY_BUDGET_BYTES),无法证明装得下")
}
gate.SyncAllowed = len(gate.Reasons) == 0
return gate
}
func libraryOverviewHandler(w http.ResponseWriter, r *http.Request, config Config) {
scope, err := scopeFromQuery(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
store, ok := libraryStore(config)
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"available": false,
"reason": "版本登记存储不可用"})
return
}
now := time.Now().UTC()
overview := libraryOverview{Scope: scope, Jobs: []governance.LibraryJob{}, Now: now,
CaptureAvailable: strings.TrimSpace(config.LibraryAgentSocket) != "",
Gate: libraryGateView{Reasons: []string{}},
Diff: libraryDiffView{State: "unknown", Reason: "生产与测试的文档级差异读取未接线", Added: []any{}, Updated: []any{}, Removed: []any{}}}
if !overview.CaptureAvailable {
overview.CaptureReason = "主机侧封存 agent 未接线"
}
// 测试封存来自归档:它只是测试侧事实。
snapshots, snapErr := readLibrarySnapshots(config.LibraryArchiveRoot)
if snapErr != nil {
overview.TestSealedReason = "归档不可读"
} else if !snapshots.Available {
overview.TestSealedReason = snapshots.Reason
} else {
for index := range snapshots.Snapshots {
if snapshots.Snapshots[index].Scope == scope {
overview.TestSealed = &snapshots.Snapshots[index]
break
}
}
if overview.TestSealed == nil {
overview.TestSealedReason = "还没有该目标的测试封存"
}
}
// 生产生效 / 预备份 / 作业来自持久登记。
versions, err := store.LibraryVersions(r.Context(), scope, 50)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot read library versions"})
return
}
overview.Production = libraryKnownView{State: "unknown", Reason: "还没有生产验证通过的生效版本记录"}
overview.Prebackup = libraryKnownView{State: "unknown", Reason: "本次目标快照还没有生产预备份"}
target := ""
if overview.TestSealed != nil {
target = overview.TestSealed.SnapshotID
}
for index := range versions {
item := versions[index]
view := libraryVersionViewOf(item)
switch item.Kind {
case governance.LibraryVersionProductionEffective:
if item.SupersededAt == nil {
overview.Production = libraryKnownView{State: "known", Version: &view}
}
case governance.LibraryVersionProductionPrebackup:
// 只有与目标快照绑定的预备份才算"这次同步前的备份"。
if target != "" && item.SnapshotID == target {
overview.Prebackup = libraryKnownView{State: "known", Version: &view}
}
}
}
recent, err := store.ListLibraryJobs(r.Context(), scope, 20)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot read library jobs"})
return
}
overview.Jobs = recent
for index := range recent {
if recent[index].State == governance.LibraryJobPending || recent[index].State == governance.LibraryJobRunning {
overview.Job = &recent[index]
break
}
}
if overview.Job == nil && len(recent) > 0 {
overview.Job = &recent[0]
}
// 差异是真实计算:生产权威版本 + 测试封存都在本地归档时给出逐篇增改撤销,
// 否则保持 unknown 并说明缺哪一侧,不用空数组冒充"无变化"。
baseSnapshot := ""
if overview.Production.State == "known" && overview.Production.Version != nil {
baseSnapshot = overview.Production.Version.SnapshotID
}
if view, ok := computeLibraryDiff(config.LibraryArchiveRoot, baseSnapshot, target); ok || view.Reason != "" {
overview.Diff = view
}
grant, hasGrant := activeGrantFor(r.Context(), config, scope, target)
overview.Authorization = authorizationViewOf(grant, hasGrant)
overview.Gate = librarySyncGateWithGrant(config, overview.Production, overview.Prebackup, target, hasGrant)
writeJSON(w, http.StatusOK, overview)
}
func libraryJobsHandler(w http.ResponseWriter, r *http.Request, config Config) {
scope, err := scopeFromQuery(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
store, ok := libraryStore(config)
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"available": false, "reason": "版本登记存储不可用", "jobs": []any{}})
return
}
items, err := store.ListLibraryJobs(r.Context(), scope, 20)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot read library jobs"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"available": true, "jobs": items})
}
func libraryJobDetailHandler(w http.ResponseWriter, r *http.Request, config Config) {
store, ok := libraryStore(config)
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"available": false, "reason": "版本登记存储不可用"})
return
}
id := strings.TrimSpace(r.PathValue("id"))
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "job id required"})
return
}
job, err := store.LibraryJob(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{"available": false, "reason": "作业不存在"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"available": true, "job": job})
}
// librarySyncEnqueue 是生产同步的唯一入口:闸口未齐时**不建作业**,直接说明缺项。
// 这样按钮永远不会假接通,也不会留下一个永远失败的空作业。
func librarySyncEnqueue(w http.ResponseWriter, r *http.Request, config Config) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
store, ok := libraryStore(config)
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"code": "library_sync_unavailable", "reasons": []string{"版本登记存储不可用"}})
return
}
var request struct {
Scope string `json:"scope"`
Target string `json:"target_snapshot_id"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&request); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if request.Scope != "internal" && request.Scope != "external" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "scope must be internal or external"})
return
}
overview := libraryOverview{Scope: request.Scope}
versions, err := store.LibraryVersions(r.Context(), request.Scope, 50)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot read library versions"})
return
}
overview.Production = libraryKnownView{State: "unknown"}
overview.Prebackup = libraryKnownView{State: "unknown"}
for index := range versions {
item := versions[index]
if item.Kind == governance.LibraryVersionProductionEffective && item.SupersededAt == nil {
view := libraryVersionViewOf(item)
overview.Production = libraryKnownView{State: "known", Version: &view}
}
if item.Kind == governance.LibraryVersionProductionPrebackup && request.Target != "" && item.SnapshotID == request.Target {
view := libraryVersionViewOf(item)
overview.Prebackup = libraryKnownView{State: "known", Version: &view}
}
}
grantFound, hasGrant := activeGrantFor(r.Context(), config, request.Scope, request.Target)
_ = grantFound
gate := librarySyncGateWithGrant(config, overview.Production, overview.Prebackup, request.Target, hasGrant)
if !gate.SyncAllowed {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"code": "library_sync_blocked", "reasons": gate.Reasons,
"production": overview.Production, "prebackup": overview.Prebackup,
})
return
}
// 走到这里说明闸口与证据都齐了才真正建作业;互斥与幂等由数据库索引保证。
digest, err := governance.LibraryRequestDigest(request.Scope, governance.LibraryJobSync,
map[string]any{"target_snapshot_id": request.Target})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot digest request"})
return
}
actor := ""
if identity, ok := sessionFrom(r.Context()); ok {
actor = identity.Username
}
now := time.Now().UTC()
jobID := fmt.Sprintf("libsync-%s-%d", request.Scope, now.UnixNano())
job, reused, err := store.CreateLibraryJob(r.Context(), governance.LibraryJob{
ID: jobID, Scope: request.Scope, Kind: governance.LibraryJobSync,
IdempotencyKey: "library.sync:" + request.Scope + ":" + request.Target, RequestDigest: digest,
TargetSnapshotID: request.Target, Actor: actor,
}, governance.SyncStages(), now)
if errors.Is(err, governance.ErrLibraryJobActive) {
writeJSON(w, http.StatusConflict, map[string]any{"code": "library_sync_active", "error": "该目标已有在飞的同步作业"})
return
}
if errors.Is(err, governance.ErrLibraryJobIdempotencyConflict) {
writeJSON(w, http.StatusConflict, map[string]any{"code": "library_sync_conflict", "error": "同一幂等键对应了不同的请求"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot create library sync job"})
return
}
if reused {
writeJSON(w, http.StatusAccepted, map[string]any{"job": job, "reused": true})
return
}
// 业务作业先落库,再投递队列:投递失败就把作业标失败,不留下"看起来在跑"的空作业。
payload, err := json.Marshal(map[string]string{
"library_job_id": job.ID, "scope": request.Scope, "target_snapshot_id": request.Target,
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot build sync payload"})
return
}
queueJob, err := config.Store.Enqueue(r.Context(), jobs.EnqueueRequest{
ID: job.ID + "-queue", Type: librarySyncJobType, Payload: payload, AvailableAt: now,
MaxAttempts: 1, IdempotencyKey: "library.sync.queue:" + job.ID,
}, now)
if err != nil || queueJob == nil {
_ = store.CancelLibraryJob(r.Context(), job.ID, "队列投递失败,未开始同步", now)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot enqueue sync job"})
return
}
if attachErr := store.AttachLibraryQueueJob(r.Context(), job.ID, queueJob.ID, now); attachErr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot attach sync job"})
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"job": job, "reused": false})
}