返回 DeepSeek-Reasonix
artifacts.go
根目录 / internal / jobs / artifacts.go
1 package jobs
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "sort"
9 "strconv"
10 "strings"
11
12 fileencoding "reasonix/internal/fileutil/encoding"
13 "reasonix/internal/store"
14 )
15
16 const (
17 jobLogExt = ".log"
18 jobMetaExt = ".json"
19 defaultTailBytes = 64 * 1024
20 mutationEvidenceVersion = 1
21 recoveredBackgroundTaskToolName = "background_task_recovery"
22 )
23
24 // ArtifactDir returns the sidecar directory for a persistent session transcript.
25 func ArtifactDir(sessionPath string) string {
26 return store.SessionJobsDir(sessionPath)
27 }
28
29 // RemoveArtifacts removes the job sidecar for a session transcript.
30 func RemoveArtifacts(sessionPath string) error {
31 dir := ArtifactDir(sessionPath)
32 if dir == "" {
33 return nil
34 }
35 return os.RemoveAll(dir)
36 }
37
38 type artifactMeta struct {
39 ID string `json:"id"`
40 Kind string `json:"kind"`
41 Label string `json:"label,omitempty"`
42 SessionID string `json:"sessionId,omitempty"`
43 OwnerID string `json:"ownerId,omitempty"`
44 Status Status `json:"status"`
45 StartedAt int64 `json:"startedAt"`
46 FinishedAt int64 `json:"finishedAt,omitempty"`
47 ArtifactComplete bool `json:"artifactComplete"`
48 ArtifactError string `json:"artifactError,omitempty"`
49 LogPath string `json:"logPath,omitempty"`
50 MutationEvidenceVersion int `json:"mutationEvidenceVersion,omitempty"`
51 MutationEvidence *artifactMutationEvidence `json:"mutationEvidence,omitempty"`
52 }
53
54 // ArtifactView is the content-free projection used by machine-facing status
55 // surfaces. It deliberately excludes labels, outputs, paths, and mutation
56 // evidence because those fields may contain user or workspace data.
57 type ArtifactView struct {
58 ID string
59 Kind string
60 Status Status
61 StartedAt int64
62 FinishedAt int64
63 ArtifactComplete bool
64 }
65
66 // ListArtifactViews returns persisted background-job metadata for one session.
67 // Missing artifact directories are normal and return an empty list.
68 func ListArtifactViews(sessionPath string) ([]ArtifactView, error) {
69 dir := ArtifactDir(sessionPath)
70 if strings.TrimSpace(dir) == "" {
71 return nil, nil
72 }
73 entries, err := os.ReadDir(dir)
74 if err != nil {
75 if os.IsNotExist(err) {
76 return nil, nil
77 }
78 return nil, err
79 }
80 out := make([]ArtifactView, 0, len(entries))
81 for _, entry := range entries {
82 if entry.IsDir() || !strings.HasSuffix(entry.Name(), jobMetaExt) {
83 continue
84 }
85 meta, err := readMeta(filepath.Join(dir, entry.Name()))
86 if err != nil {
87 return nil, err
88 }
89 if strings.TrimSpace(meta.ID) == "" {
90 continue
91 }
92 artifactComplete := persistedArtifactComplete(dir, meta)
93 out = append(out, ArtifactView{
94 ID: meta.ID,
95 Kind: meta.Kind,
96 Status: meta.Status,
97 StartedAt: meta.StartedAt,
98 FinishedAt: meta.FinishedAt,
99 ArtifactComplete: artifactComplete,
100 })
101 }
102 sort.Slice(out, func(i, j int) bool {
103 if out[i].StartedAt == out[j].StartedAt {
104 return out[i].ID < out[j].ID
105 }
106 return out[i].StartedAt > out[j].StartedAt
107 })
108 return out, nil
109 }
110
111 func persistedArtifactComplete(dir string, meta artifactMeta) bool {
112 switch meta.Status {
113 case Done, Failed, Killed, Interrupted:
114 default:
115 return false
116 }
117 if !meta.ArtifactComplete || strings.TrimSpace(meta.ArtifactError) != "" {
118 return false
119 }
120 logName := strings.TrimSpace(meta.LogPath)
121 if logName == "" {
122 logName = meta.ID + jobLogExt
123 }
124 info, err := os.Stat(filepath.Join(dir, filepath.Base(logName)))
125 return err == nil && info.Mode().IsRegular()
126 }
127
128 // artifactMutationEvidence deliberately excludes receipt args, commands, and
129 // review contents. After a restart the parent must re-inspect and re-verify the
130 // recovered mutation rather than trusting stale child sign-off evidence.
131 type artifactMutationEvidence struct {
132 Risk string `json:"risk"`
133 Paths []string `json:"paths,omitempty"`
134 }
135
136 func writeMeta(path string, meta artifactMeta) error {
137 if path == "" {
138 return fmt.Errorf("empty metadata path")
139 }
140 if err := ensurePrivateArtifactDir(filepath.Dir(path)); err != nil {
141 return err
142 }
143 b, err := json.MarshalIndent(meta, "", " ")
144 if err != nil {
145 return err
146 }
147 tmp, err := os.CreateTemp(filepath.Dir(path), ".job-meta-*.tmp")
148 if err != nil {
149 return err
150 }
151 tmpPath := tmp.Name()
152 if _, err := tmp.Write(b); err != nil {
153 tmp.Close()
154 os.Remove(tmpPath)
155 return err
156 }
157 if err := tmp.Close(); err != nil {
158 os.Remove(tmpPath)
159 return err
160 }
161 return os.Rename(tmpPath, path)
162 }
163
164 func readMeta(path string) (artifactMeta, error) {
165 var meta artifactMeta
166 b, err := fileencoding.ReadFileUTF8(path)
167 if err != nil {
168 return meta, err
169 }
170 if err := json.Unmarshal(b, &meta); err != nil {
171 return meta, err
172 }
173 return meta, nil
174 }
175
176 func maxJobSeq(id string) int {
177 _, tail, ok := strings.Cut(id, "-")
178 if !ok {
179 return 0
180 }
181 n, err := strconv.Atoi(tail)
182 if err != nil {
183 return 0
184 }
185 return n
186 }
187
188 func appendTail(buf []byte, p []byte, limit int) []byte {
189 if limit <= 0 {
190 return nil
191 }
192 if len(p) >= limit {
193 out := make([]byte, limit)
194 copy(out, p[len(p)-limit:])
195 return out
196 }
197 total := len(buf) + len(p)
198 if total <= limit {
199 out := make([]byte, total)
200 copy(out, buf)
201 copy(out[len(buf):], p)
202 return out
203 }
204 keep := limit - len(p)
205 out := make([]byte, limit)
206 copy(out, buf[len(buf)-keep:])
207 copy(out[keep:], p)
208 return out
209 }
210
210 lines GO