Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

265 lines
6.6 KiB
Go

package server
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/parser"
"go.kenn.io/agentsview/internal/timeutil"
)
type stagedUpload struct {
tempPath string
tempDir string
finalPath string
}
type committedUpload struct {
finalPath string
backupPath string
hadPrevious bool
movedFinal bool
}
// stageUpload writes the uploaded file to a temporary path in
// <dataDir>/uploads/<project>. The caller must either commit
// or remove the staged file.
func (s *Server) stageUpload(
project string, filename string, src io.Reader,
) (stagedUpload, error) {
uploadDir := filepath.Join(
s.cfg.DataDir, "uploads", project,
)
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
return stagedUpload{}, fmt.Errorf(
"creating upload directory: %w", err,
)
}
tempDir, err := os.MkdirTemp(
uploadDir, "."+strings.TrimSuffix(filename, ".jsonl")+".*.tmp",
)
if err != nil {
return stagedUpload{}, fmt.Errorf(
"saving uploaded file: %w", err,
)
}
finalPath := filepath.Join(uploadDir, filename)
tempPath := filepath.Join(tempDir, filename)
dest, err := os.Create(tempPath)
if err != nil {
_ = os.RemoveAll(tempDir)
return stagedUpload{}, fmt.Errorf(
"saving uploaded file: %w", err,
)
}
if _, err := io.Copy(dest, src); err != nil {
_ = dest.Close()
_ = os.RemoveAll(tempDir)
return stagedUpload{}, fmt.Errorf(
"writing uploaded file: %w", err,
)
}
if err := dest.Close(); err != nil {
_ = os.RemoveAll(tempDir)
return stagedUpload{}, fmt.Errorf(
"closing uploaded file: %w", err,
)
}
return stagedUpload{
tempPath: tempPath,
tempDir: tempDir,
finalPath: finalPath,
}, nil
}
func commitUpload(upload stagedUpload) (committedUpload, error) {
state := committedUpload{finalPath: upload.finalPath}
info, err := os.Lstat(upload.finalPath)
switch {
case err == nil:
if !info.Mode().IsRegular() {
return state, fmt.Errorf(
"committing upload: destination is not a regular file",
)
}
backupPath, err := createUploadBackupPath(upload.finalPath)
if err != nil {
return state, err
}
state.backupPath = backupPath
state.hadPrevious = true
if err := os.Rename(upload.finalPath, backupPath); err != nil {
return state, fmt.Errorf(
"backing up existing upload: %w", err,
)
}
case errors.Is(err, os.ErrNotExist):
default:
return state, fmt.Errorf(
"checking upload destination: %w", err,
)
}
if err := os.Rename(upload.tempPath, upload.finalPath); err != nil {
if state.hadPrevious {
if rbErr := os.Rename(state.backupPath, upload.finalPath); rbErr != nil {
return state, fmt.Errorf(
"committing upload: %w (restore previous upload failed: %v)",
err, rbErr,
)
}
state.hadPrevious = false
state.backupPath = ""
}
return state, fmt.Errorf("committing upload: %w", err)
}
state.movedFinal = true
return state, nil
}
func createUploadBackupPath(finalPath string) (string, error) {
f, err := os.CreateTemp(
filepath.Dir(finalPath),
"."+filepath.Base(finalPath)+".*.bak",
)
if err != nil {
return "", fmt.Errorf("creating upload backup: %w", err)
}
path := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(path)
return "", fmt.Errorf("closing upload backup: %w", err)
}
if err := os.Remove(path); err != nil {
return "", fmt.Errorf("preparing upload backup: %w", err)
}
return path, nil
}
func rollbackCommittedUpload(upload committedUpload) error {
if !upload.movedFinal {
return nil
}
if err := os.Remove(upload.finalPath); err != nil &&
!errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("removing committed upload: %w", err)
}
if upload.hadPrevious {
if err := os.Rename(upload.backupPath, upload.finalPath); err != nil {
return fmt.Errorf("restoring previous upload: %w", err)
}
}
return nil
}
func cleanupCommittedUpload(upload committedUpload) {
if upload.hadPrevious && upload.backupPath != "" {
_ = os.Remove(upload.backupPath)
}
}
// sessionBatchWriteFromParsed maps parsed session and messages
// to DB types for an upload transaction.
func sessionBatchWriteFromParsed(
sess parser.ParsedSession,
msgs []parser.ParsedMessage,
) db.SessionBatchWrite {
hasTotal, hasPeak := sess.TokenCoverage(msgs)
dbSess := db.Session{
ID: sess.ID,
Project: sess.Project,
Machine: sess.Machine,
Agent: string(sess.Agent),
MessageCount: sess.MessageCount,
UserMessageCount: sess.UserMessageCount,
ParentSessionID: strPtr(sess.ParentSessionID),
RelationshipType: string(sess.RelationshipType),
TotalOutputTokens: sess.TotalOutputTokens,
PeakContextTokens: sess.PeakContextTokens,
HasTotalOutputTokens: hasTotal,
HasPeakContextTokens: hasPeak,
FilePath: strPtr(sess.File.Path),
FileSize: int64Ptr(sess.File.Size),
FileMtime: int64Ptr(sess.File.Mtime),
FileHash: strPtr(sess.File.Hash),
}
if sess.FirstMessage != "" {
dbSess.FirstMessage = &sess.FirstMessage
}
dbSess.SessionName = db.ParsedSessionName(sess)
if !sess.StartedAt.IsZero() {
dbSess.StartedAt = timeutil.Ptr(sess.StartedAt)
}
if !sess.EndedAt.IsZero() {
dbSess.EndedAt = timeutil.Ptr(sess.EndedAt)
}
dbMsgs := make([]db.Message, len(msgs))
for i, m := range msgs {
hasCtx, hasOut := m.TokenPresence()
dbMsgs[i] = db.Message{
SessionID: sess.ID,
Ordinal: m.Ordinal,
Role: string(m.Role),
Content: m.Content,
Timestamp: timeutil.Format(m.Timestamp),
HasThinking: m.HasThinking,
HasToolUse: m.HasToolUse,
ContentLength: m.ContentLength,
Model: m.Model,
TokenUsage: m.TokenUsage,
ContextTokens: m.ContextTokens,
OutputTokens: m.OutputTokens,
HasContextTokens: hasCtx,
HasOutputTokens: hasOut,
}
}
// Signals and Findings are intentionally not computed for uploads:
// the upload path does not run the sync engine's derived-data
// pipeline, so zero-valued signal columns and no findings rows are
// the expected state for freshly uploaded sessions.
return db.SessionBatchWrite{
Session: dbSess,
Messages: dbMsgs,
ReplaceMessages: true,
}
}
// isSafeName rejects names containing path separators, "..",
// or starting with "." to prevent directory traversal.
func isSafeName(name string) bool {
if name == "" {
return false
}
if strings.ContainsAny(name, "/\\") {
return false
}
if strings.HasPrefix(name, ".") {
return false
}
return true
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
func int64Ptr(n int64) *int64 {
if n == 0 {
return nil
}
return &n
}