Files
artex/server/task_archive_package.go
T
dela 97668dab7e
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
docs / links (push) Waiting to run
web / web (push) Waiting to run
Localize user-facing UI, agent output, and backend messages to Simplified Chinese
2026-10-09 14:08:31 +08:00

727 lines
22 KiB
Go

package server
import (
"archive/tar"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"os"
"path/filepath"
"strconv"
"strings"
pgdb "github.com/Autumn-27/artex/db"
"github.com/klauspost/compress/zstd"
)
const (
archiveDirMode = 0o700
archiveFileMode = 0o600
maxArchiveFiles = 1_000_000
maxArchiveBytes = int64(1 << 47) // 128 TiB safety ceiling for corrupt headers.
)
// errArchiveStagedAndOriginalCoexist 는 보관 패키지를 삭제하려 임시 파일로 옮기는 과정에서
// 원본 파일과 삭제 임시 파일(.deleting-N)이 동시에 존재하는 모순 상태를 사용자에게 알린다.
//
// 호출 그래프 판정(사용자 노출 전용): stageTaskArchivePackageDelete(아래 506행) →
// deleteTaskArchive(task_archives.go:400) → 백그라운드 아카이브 워커 runOneTaskArchiveJob
// (task_archives.go:102) → FailTaskArchiveJob 이 task_archives.error 컬럼에 저장
// (db/task_archives.go:435) → API 가 TaskArchive.Error("error" JSON)로 노출 →
// tasks/page.tsx 의 TaskArchivesPanel 이 {archive.error} 로 화면에 직접 표시.
// actool·planner 되먹임 경로 0(두뇌 입력 아님)이라 한국어화한다. 같은 전파 경로의 형제 오류
// (archiveTask 의 task_archives.go:120·132·173, validateArchivePath 의 421·433)도 이미 한국어다.
const errArchiveStagedAndOriginalCoexist = "归档包的原始文件与待删除临时文件同时存在"
type archiveFileMove struct {
Source string `json:"source"`
Relative string `json:"relative"`
}
type archiveStageJournal struct {
ArchiveID int64 `json:"archive_id"`
TaskID string `json:"task_id"`
Moves []archiveFileMove `json:"moves"`
}
type archiveRestoreJournal struct {
ArchiveID int64 `json:"archive_id"`
TaskID int64 `json:"task_id"`
Moves []restoredArchivePath `json:"moves"`
}
type taskArchiveFileStage struct {
root string
payload string
journal archiveStageJournal
done bool
}
type restoredArchivePath struct {
Source string
Destination string
}
type taskArchiveRestoreFiles struct {
extracted string
moves []restoredArchivePath
done bool
}
func writeArchiveJournal(path string, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, archiveFileMode)
if err != nil {
return err
}
if _, err := file.Write(raw); err != nil {
_ = file.Close()
return err
}
if err := file.Sync(); err != nil {
_ = file.Close()
return err
}
return file.Close()
}
func taskArchiveRoot(dataDir string) string {
return filepath.Join(dataDir, "archives", "tasks")
}
func taskArchivePath(dataDir string, archiveID int64, taskID string) string {
return filepath.Join(taskArchiveRoot(dataDir), fmt.Sprintf("task-%s-%d.tar.zst", taskID, archiveID))
}
func stageTaskArchiveFiles(dataDir string, archiveID int64, taskID string, explorationID int64) (*taskArchiveFileStage, error) {
root := filepath.Join(taskArchiveRoot(dataDir), ".staging", strconv.FormatInt(archiveID, 10))
stage := &taskArchiveFileStage{root: root, payload: filepath.Join(root, "payload")}
journalPath := filepath.Join(root, "journal.json")
if raw, err := os.ReadFile(journalPath); err == nil {
if err := json.Unmarshal(raw, &stage.journal); err != nil {
return nil, fmt.Errorf("read task archive staging journal: %w", err)
}
// The journal is written before the first rename, so its presence does not mean
// the payload is complete: a previous round may have failed mid-loop with a
// rollback that itself errored, which leaves the root in place. Replay the moves
// rather than packaging a payload that is missing transcripts or workspace files.
if err := os.MkdirAll(filepath.Join(stage.payload, "files"), archiveDirMode); err != nil {
return nil, err
}
if err := stage.applyMoves(); err != nil {
_ = stage.rollback()
return nil, err
}
return stage, nil
} else if !os.IsNotExist(err) {
return nil, err
}
if err := os.MkdirAll(filepath.Join(stage.payload, "files"), archiveDirMode); err != nil {
return nil, err
}
stage.journal = archiveStageJournal{ArchiveID: archiveID, TaskID: taskID}
targets := []archiveFileMove{}
taskDir := filepath.Join(dataDir, "tasks", taskID)
if _, err := os.Lstat(taskDir); err == nil {
targets = append(targets, archiveFileMove{Source: taskDir, Relative: filepath.Join("files", "tasks", taskID)})
} else if !os.IsNotExist(err) {
return nil, err
}
transcriptDir := filepath.Join(dataDir, "transcripts")
entries, err := os.ReadDir(transcriptDir)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
prefix := fmt.Sprintf("exp%d-", explorationID)
for _, entry := range entries {
name := entry.Name()
if !strings.HasPrefix(name, prefix) || (!entry.IsDir() && !strings.HasSuffix(name, ".jsonl")) {
continue
}
targets = append(targets, archiveFileMove{
Source: filepath.Join(transcriptDir, name), Relative: filepath.Join("files", "transcripts", name),
})
}
// Persist the complete plan before the first rename. Recovery can therefore
// roll back any prefix of the moves after an abrupt process termination.
stage.journal.Moves = targets
if err := writeArchiveJournal(journalPath, stage.journal); err != nil {
_ = stage.rollback()
return nil, err
}
if err := stage.applyMoves(); err != nil {
_ = stage.rollback()
return nil, err
}
return stage, nil
}
// applyMoves performs the renames recorded in the journal. Entries already sitting
// in the payload are skipped, so an interrupted staging round can be resumed in
// place without moving anything twice.
func (s *taskArchiveFileStage) applyMoves() error {
for _, move := range s.journal.Moves {
destination := filepath.Join(s.payload, move.Relative)
if _, err := os.Lstat(destination); err == nil {
continue
} else if !os.IsNotExist(err) {
return err
}
if _, err := os.Lstat(move.Source); err != nil {
if os.IsNotExist(err) {
// Neither side exists: the path was removed outside the archive flow
// after the journal was written. Nothing can be staged for it.
continue
}
return err
}
if err := os.MkdirAll(filepath.Dir(destination), archiveDirMode); err != nil {
return err
}
if err := os.Rename(move.Source, destination); err != nil {
return fmt.Errorf("stage task archive path %s: %w", move.Source, err)
}
}
return nil
}
func (s *taskArchiveFileStage) rollback() error {
if s == nil || s.done {
return nil
}
var errs []error
for i := len(s.journal.Moves) - 1; i >= 0; i-- {
move := s.journal.Moves[i]
staged := filepath.Join(s.payload, move.Relative)
if _, err := os.Lstat(staged); os.IsNotExist(err) {
continue
} else if err != nil {
errs = append(errs, err)
continue
}
if _, err := os.Lstat(move.Source); err == nil {
errs = append(errs, fmt.Errorf("archive rollback destination exists: %s", move.Source))
continue
} else if !os.IsNotExist(err) {
errs = append(errs, err)
continue
}
if err := os.MkdirAll(filepath.Dir(move.Source), 0o755); err != nil {
errs = append(errs, err)
continue
}
if err := os.Rename(staged, move.Source); err != nil {
errs = append(errs, err)
}
}
if len(errs) == 0 {
if err := os.RemoveAll(s.root); err != nil {
errs = append(errs, err)
}
}
s.done = true
return errors.Join(errs...)
}
func (s *taskArchiveFileStage) commit() error {
if s == nil || s.done {
return nil
}
s.done = true
return os.RemoveAll(s.root)
}
func installTaskArchiveFiles(dataDir, extractedDir, taskID string, archiveID int64) (*taskArchiveRestoreFiles, error) {
stage := &taskArchiveRestoreFiles{extracted: extractedDir}
numericTaskID, err := strconv.ParseInt(taskID, 10, 64)
if err != nil || numericTaskID <= 0 {
return nil, fmt.Errorf("invalid restore task id %q", taskID)
}
sources := []restoredArchivePath{}
workspace := filepath.Join(extractedDir, "files", "tasks", taskID)
if _, err := os.Lstat(workspace); err == nil {
sources = append(sources, restoredArchivePath{Source: workspace, Destination: filepath.Join(dataDir, "tasks", taskID)})
} else if !os.IsNotExist(err) {
return nil, err
}
transcripts := filepath.Join(extractedDir, "files", "transcripts")
entries, err := os.ReadDir(transcripts)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
for _, entry := range entries {
sources = append(sources, restoredArchivePath{
Source: filepath.Join(transcripts, entry.Name()), Destination: filepath.Join(dataDir, "transcripts", entry.Name()),
})
}
for _, move := range sources {
if _, err := os.Lstat(move.Destination); err == nil {
return nil, fmt.Errorf("restore destination already exists: %s", move.Destination)
} else if !os.IsNotExist(err) {
return nil, err
}
}
stage.moves = sources
journal := archiveRestoreJournal{ArchiveID: archiveID, TaskID: numericTaskID, Moves: sources}
if err := writeArchiveJournal(filepath.Join(extractedDir, "restore-journal.json"), journal); err != nil {
return nil, err
}
for _, move := range sources {
if err := os.MkdirAll(filepath.Dir(move.Destination), 0o755); err != nil {
_ = stage.rollback()
return nil, err
}
if err := os.Rename(move.Source, move.Destination); err != nil {
_ = stage.rollback()
return nil, err
}
}
return stage, nil
}
func (s *taskArchiveRestoreFiles) rollback() error {
if s == nil || s.done {
return nil
}
var errs []error
for i := len(s.moves) - 1; i >= 0; i-- {
move := s.moves[i]
if _, err := os.Lstat(move.Destination); os.IsNotExist(err) {
continue
} else if err != nil {
errs = append(errs, err)
continue
}
if _, err := os.Lstat(move.Source); err == nil {
errs = append(errs, fmt.Errorf("restore rollback source exists: %s", move.Source))
continue
} else if !os.IsNotExist(err) {
errs = append(errs, err)
continue
}
if err := os.MkdirAll(filepath.Dir(move.Source), archiveDirMode); err != nil {
errs = append(errs, err)
continue
}
if err := os.Rename(move.Destination, move.Source); err != nil {
errs = append(errs, err)
}
}
s.done = true
return errors.Join(errs...)
}
func (s *taskArchiveRestoreFiles) commit() error {
if s == nil || s.done {
return nil
}
s.done = true
return os.RemoveAll(s.extracted)
}
// recoverTaskArchiveRestoreStages resolves file installs left by an interrupted
// restore. If PostgreSQL committed, installed files are authoritative and only
// the extraction directory is stale. Otherwise all completed renames are moved
// back so the persistent restore job can retry from a clean destination.
func recoverTaskArchiveRestoreStages(dataDir string, pg *pgdb.DB) error {
parent := filepath.Join(taskArchiveRoot(dataDir), ".restore")
entries, err := os.ReadDir(parent)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var errs []error
for _, entry := range entries {
if !entry.IsDir() {
continue
}
root := filepath.Join(parent, entry.Name())
raw, err := os.ReadFile(filepath.Join(root, "restore-journal.json"))
if os.IsNotExist(err) {
// Extraction was interrupted before any destination rename.
if err := os.RemoveAll(root); err != nil {
errs = append(errs, err)
}
continue
}
if err != nil {
errs = append(errs, err)
continue
}
var journal archiveRestoreJournal
if err := json.Unmarshal(raw, &journal); err != nil {
errs = append(errs, err)
continue
}
restored, err := pg.IsTaskArchiveRestored(journal.ArchiveID)
if errors.Is(err, pgdb.ErrTaskArchiveNotFound) && journal.TaskID > 0 {
task, taskErr := pg.GetTask(journal.TaskID)
if taskErr != nil {
errs = append(errs, taskErr)
continue
}
restored = task != nil
err = nil
}
if err != nil {
errs = append(errs, err)
continue
}
if restored {
if err := os.RemoveAll(root); err != nil {
errs = append(errs, err)
}
continue
}
stage := &taskArchiveRestoreFiles{extracted: root, moves: journal.Moves}
if err := stage.rollback(); err != nil {
errs = append(errs, err)
continue
}
if err := os.RemoveAll(root); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// recoverTaskArchiveStages resolves file renames left by an interrupted archive.
// A committed cold task already has a verified package, so stale staging can be
// discarded; otherwise files are moved back before the persistent job retries.
func recoverTaskArchiveStages(dataDir string, pg *pgdb.DB) error {
parent := filepath.Join(taskArchiveRoot(dataDir), ".staging")
entries, err := os.ReadDir(parent)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var errs []error
for _, entry := range entries {
if !entry.IsDir() {
continue
}
root := filepath.Join(parent, entry.Name())
raw, err := os.ReadFile(filepath.Join(root, "journal.json"))
if os.IsNotExist(err) {
// Either staging died between creating the directory tree and writing the
// journal — no rename had run, so payload holds nothing — or commit/rollback
// failed part-way through removing the root, in which case payload only holds
// copies already inside the package. Without a journal there is nothing to
// roll back, and reporting an error here keeps the archive worker from ever
// starting, so discard the directory instead.
if err := os.RemoveAll(root); err != nil {
errs = append(errs, err)
}
continue
}
if err != nil {
errs = append(errs, err)
continue
}
var journal archiveStageJournal
if err := json.Unmarshal(raw, &journal); err != nil {
errs = append(errs, err)
continue
}
archive, getErr := pg.GetTaskArchive(journal.ArchiveID)
if getErr != nil {
errs = append(errs, getErr)
continue
}
if archive != nil && archive.State == pgdb.ArchiveReady {
if err := os.RemoveAll(root); err != nil {
errs = append(errs, err)
}
continue
}
stage := &taskArchiveFileStage{root: root, payload: filepath.Join(root, "payload"), journal: journal}
if err := stage.rollback(); err != nil {
errs = append(errs, err)
}
if archive != nil && archive.ArchivePath != "" {
_ = os.Remove(archive.ArchivePath)
}
}
return errors.Join(errs...)
}
func recoverTaskArchiveDeletePackages(dataDir string, pg *pgdb.DB) error {
root := taskArchiveRoot(dataDir)
entries, err := os.ReadDir(root)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var errs []error
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
marker := strings.LastIndex(name, ".deleting-")
if marker < 0 {
continue
}
archiveID, err := strconv.ParseInt(name[marker+len(".deleting-"):], 10, 64)
if err != nil || archiveID <= 0 {
continue
}
staged := filepath.Join(root, name)
original := filepath.Join(root, name[:marker])
archive, err := pg.GetTaskArchive(archiveID)
if err != nil {
errs = append(errs, err)
continue
}
if archive == nil {
if err := os.Remove(staged); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
continue
}
if archive.State == pgdb.DeleteQueued || archive.State == pgdb.Deleting || archive.State == pgdb.DeleteFailed {
// The idempotent delete worker consumes the staged path directly.
continue
}
if _, err := os.Lstat(original); err == nil {
errs = append(errs, fmt.Errorf("archive delete recovery destination exists: %s", original))
continue
} else if !os.IsNotExist(err) {
errs = append(errs, err)
continue
}
if err := os.Rename(staged, original); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func stageTaskArchivePackageDelete(archivePath string, archiveID int64) (string, bool, error) {
staged := archivePath + fmt.Sprintf(".deleting-%d", archiveID)
if _, err := os.Lstat(staged); err == nil {
if _, originalErr := os.Lstat(archivePath); originalErr == nil {
return staged, false, errors.New(errArchiveStagedAndOriginalCoexist)
} else if !os.IsNotExist(originalErr) {
return staged, false, originalErr
}
return staged, true, nil
} else if !os.IsNotExist(err) {
return staged, false, err
}
if err := os.Rename(archivePath, staged); err == nil {
return staged, true, nil
} else if !os.IsNotExist(err) {
return staged, false, err
}
return staged, false, nil
}
func writeTaskArchivePackage(path, payloadDir string, snapshot *pgdb.TaskArchiveSnapshot) (originalSize, compressedSize int64, checksum string, err error) {
if snapshot == nil {
return 0, 0, "", errors.New("nil task archive snapshot")
}
if err = os.MkdirAll(filepath.Dir(path), archiveDirMode); err != nil {
return 0, 0, "", err
}
manifestFile, err := os.OpenFile(
filepath.Join(payloadDir, "manifest.json"),
os.O_CREATE|os.O_TRUNC|os.O_WRONLY,
archiveFileMode,
)
if err != nil {
return 0, 0, "", err
}
encodeErr := json.NewEncoder(manifestFile).Encode(snapshot)
var syncErr error
if encodeErr == nil {
syncErr = manifestFile.Sync()
}
if err := errors.Join(encodeErr, syncErr, manifestFile.Close()); err != nil {
return 0, 0, "", err
}
temporary := path + ".partial"
_ = os.Remove(temporary)
file, err := os.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, archiveFileMode)
if err != nil {
return 0, 0, "", err
}
committed := false
defer func() {
_ = file.Close()
if !committed {
_ = os.Remove(temporary)
}
}()
hasher := sha256.New()
multi := io.MultiWriter(file, hasher)
encoder, err := zstd.NewWriter(multi,
zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(10)),
zstd.WithEncoderCRC(true),
)
if err != nil {
return 0, 0, "", err
}
tarWriter := tar.NewWriter(encoder)
walkErr := filepath.WalkDir(payloadDir, func(current string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
relative, err := filepath.Rel(payloadDir, current)
if err != nil || relative == "." {
return err
}
info, err := entry.Info()
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
// 归档格式端到端只支持普通文件与目录(解包端对其它类型直接报错),
// 无法还原符号链接。跳过而非整包失败:不读取链接目标(lstat,不越出目录树),
// 也不写入 symlink 条目;链接指向树内时目标文件本身仍会被单独遍历归档。
log.Printf("[task-archive] 跳过符号链接(归档不支持,不影响其它文件):%s", current)
return nil
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = filepath.ToSlash(relative)
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if entry.IsDir() {
return nil
}
originalSize += info.Size()
input, err := os.Open(current)
if err != nil {
return err
}
_, copyErr := io.Copy(tarWriter, input)
closeErr := input.Close()
return errors.Join(copyErr, closeErr)
})
if walkErr != nil {
_ = tarWriter.Close()
_ = encoder.Close()
return 0, 0, "", walkErr
}
if err := tarWriter.Close(); err != nil {
_ = encoder.Close()
return 0, 0, "", err
}
if err := encoder.Close(); err != nil {
return 0, 0, "", err
}
if err := file.Sync(); err != nil {
return 0, 0, "", err
}
if err := file.Close(); err != nil {
return 0, 0, "", err
}
stat, err := os.Stat(temporary)
if err != nil {
return 0, 0, "", err
}
compressedSize = stat.Size()
checksum = hex.EncodeToString(hasher.Sum(nil))
if err := os.Rename(temporary, path); err != nil {
return 0, 0, "", err
}
if err := os.Chmod(path, archiveFileMode); err != nil {
return 0, 0, "", err
}
committed = true
return originalSize, compressedSize, checksum, nil
}
func extractTaskArchivePackage(path, expectedSHA, destination string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
if expectedSHA != "" {
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return err
}
if hex.EncodeToString(hasher.Sum(nil)) != strings.ToLower(expectedSHA) {
return errors.New("task archive checksum mismatch")
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return err
}
}
decoder, err := zstd.NewReader(file)
if err != nil {
return err
}
defer decoder.Close()
tarReader := tar.NewReader(decoder)
if err := os.MkdirAll(destination, archiveDirMode); err != nil {
return err
}
var entries int
var total int64
for {
header, err := tarReader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
entries++
if entries > maxArchiveFiles || header.Size < 0 || total+header.Size > maxArchiveBytes {
return errors.New("task archive exceeds extraction safety limits")
}
total += header.Size
clean := filepath.Clean(filepath.FromSlash(header.Name))
if clean == "." || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return fmt.Errorf("unsafe task archive path %q", header.Name)
}
target := filepath.Join(destination, clean)
if relative, err := filepath.Rel(destination, target); err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return fmt.Errorf("unsafe task archive target %q", header.Name)
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, archiveDirMode); err != nil {
return err
}
case tar.TypeReg, tar.TypeRegA:
if err := os.MkdirAll(filepath.Dir(target), archiveDirMode); err != nil {
return err
}
output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, fs.FileMode(header.Mode)&0o777)
if err != nil {
return err
}
_, copyErr := io.CopyN(output, tarReader, header.Size)
closeErr := output.Close()
if err := errors.Join(copyErr, closeErr); err != nil {
return err
}
default:
return fmt.Errorf("unsupported task archive entry type %d", header.Typeflag)
}
}
return nil
}