Repository navigation
Expand file tree
/
Copy pathattachments.go
More file actions
241 lines (227 loc) · 8.52 KB
/
Copy pathattachments.go
File metadata and controls
241 lines (227 loc) · 8.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
package exec
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/Agent-Field/codeaf/internal/cas"
)
// An attachment used to be a reference to somebody's own filesystem. The job
// read the bytes when it ran, which meant a PDF moved into a different folder
// on Tuesday broke a retry of Monday's work, and a follow-up the next morning
// carried nothing at all — the durable record named a file, and the file was
// the person's, not ours.
//
// So an attachment is copied when it is mentioned. The bytes go into the
// content-addressed store the moment the message is sent, the message carries
// a reference to them, and every later reader — a retry, a follow-up in a new
// session, a continuation a week later — reads our copy. The person's file is
// opened once, read-only, and never touched again.
const (
// MaxAttachmentBytes is the ceiling on one attached file.
MaxAttachmentBytes = 25 << 20
attachmentDir = "attachments"
)
// KeepAttachment copies one file the person explicitly attached into the store
// at root and returns the durable reference to it. Only the named file is
// read: nothing here walks a directory, follows a listing, or reaches for a
// sibling.
func KeepAttachment(root, path string) (string, error) {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
return "", fmt.Errorf("keep attachment: path is required")
}
absolute, err := filepath.Abs(trimmed)
if err != nil {
return "", fmt.Errorf("keep attachment %s: %w", filepath.Base(trimmed), err)
}
info, err := os.Stat(absolute)
if err != nil {
return "", fmt.Errorf("keep attachment %s: file is unavailable", filepath.Base(absolute))
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("keep attachment %s: not a file", filepath.Base(absolute))
}
if info.Size() > MaxAttachmentBytes {
return "", fmt.Errorf("keep attachment %s: over the 25 MB limit", filepath.Base(absolute))
}
blobs, err := cas.New(root)
if err != nil {
return "", fmt.Errorf("keep attachment %s: %w", filepath.Base(absolute), err)
}
// Identical bytes attached twice cost one copy: the digest is the name, so
// the second Put finds the object already there.
ref, err := blobs.PutFile(absolute)
if err != nil {
return "", fmt.Errorf("keep attachment %s: %w", filepath.Base(absolute), err)
}
return cas.Reference(ref, absolute), nil
}
// StagedAttachments is what one job's workspace holds after staging.
type StagedAttachments struct {
// Documents and Images are workspace-relative, which is the spelling the
// leaf's own tools take: read_document and view_image both address the
// workspace.
Documents []string
Images []string
// ImageFiles are the same images as absolute paths, for the turn content a
// model with eyes receives directly.
ImageFiles []string
}
// StageAttachments materializes a node's attachments as immutable workspace
// inputs. References resolve out of the store; a path recorded before copies
// existed still resolves from disk, so old work keeps running.
func StageAttachments(space *Workspace, root string, attachments []string) (StagedAttachments, error) {
var staged StagedAttachments
if space == nil {
return staged, fmt.Errorf("stage attachments: nil workspace")
}
seen := make(map[string]bool)
for _, attachment := range attachments {
extension := strings.ToLower(filepath.Ext(cas.SourcePath(attachment)))
document := extension == ".pdf" || extension == ".docx" || extension == ".pptx"
image := isStageableImage(extension)
if !document && !image {
continue
}
data, name, err := readAttachment(root, attachment)
if err != nil {
return StagedAttachments{}, err
}
relative := filepath.Join(attachmentDir, stagedName(name, data))
if seen[relative] {
continue
}
seen[relative] = true
target, err := space.Resolve(relative)
if err != nil {
return StagedAttachments{}, fmt.Errorf("stage attachment %s: %w", name, err)
}
if err := writeStagedFile(target, data); err != nil {
return StagedAttachments{}, fmt.Errorf("stage attachment %s: %w", name, err)
}
slashed := filepath.ToSlash(relative)
if document {
staged.Documents = append(staged.Documents, slashed)
continue
}
staged.Images = append(staged.Images, slashed)
staged.ImageFiles = append(staged.ImageFiles, target)
}
return staged, nil
}
// attachedImageNote is what a leaf that cannot see is told about the images it
// was given. Either a vision model can be asked to describe them, or nothing
// here can look at all — and in that case the leaf must say so in its answer,
// because a person who attached a screenshot and gets a confident reply that
// never mentions it has been quietly lied to.
func attachedImageNote(names []string, proxy bool) string {
if len(names) == 0 {
return ""
}
list := strings.Join(names, ", ")
if proxy {
return "Attached image(s), already in your workspace: " + list +
". The model working this job cannot see images; view_image will have a vision model" +
" look at one and describe it, so use it before reasoning about what they contain."
}
return "Attached image(s), already in your workspace: " + list +
". Nothing available to this job can see an image — the working model has no vision and no" +
" vision model is configured. Do what you can without them and say plainly in your answer" +
" that the attached image could not be read."
}
func (l *Linear) visionProxy() bool {
return l.media != nil && strings.TrimSpace(l.media.VisionModel) != "" && l.media.VisionClient != nil
}
// workspaceNames renders staged paths the way the leaf's own tools take them.
func (l *Linear) workspaceNames(paths []string) []string {
names := make([]string, 0, len(paths))
for _, path := range paths {
if l.workspace != nil {
if relative, err := filepath.Rel(l.workspace.Root(), path); err == nil &&
!strings.HasPrefix(relative, "..") {
names = append(names, filepath.ToSlash(relative))
continue
}
}
names = append(names, filepath.ToSlash(path))
}
return names
}
func isStageableImage(extension string) bool {
switch extension {
case ".png", ".jpg", ".jpeg", ".webp", ".gif":
return true
default:
return false
}
}
// readAttachment prefers our own copy and falls back to the person's file,
// which is what a record journaled before copy-at-mention still points at.
func readAttachment(root, attachment string) ([]byte, string, error) {
ref, source, isReference := cas.ParseReference(attachment)
name := filepath.Base(source)
if name == "." || name == string(filepath.Separator) {
name = "attachment"
}
if isReference && strings.TrimSpace(root) != "" {
if blobs, err := cas.New(root); err == nil {
if data, err := readBlob(blobs, ref); err == nil {
return data, name, nil
}
}
}
if source == "" || isReference && source == attachment {
return nil, name, fmt.Errorf("stage attachment %s: the stored copy is missing", name)
}
data, err := os.ReadFile(source)
if err != nil {
return nil, name, fmt.Errorf("stage attachment %s: file is unavailable", name)
}
if len(data) > MaxAttachmentBytes {
return nil, name, fmt.Errorf("stage attachment %s: over the 25 MB limit", name)
}
return data, name, nil
}
// readBlob is our copy of an attachment, read ONCE. The store's Get opens the
// object and Path names it, and this used to do both — one handle opened, never
// read from, and closed again beside the read that actually happened — so a 25
// MB attachment cost two opens of the same file for one copy of its bytes. Path
// is the half that answers the question: it validates the reference, and
// os.ReadFile sizes its buffer from the file rather than growing one.
func readBlob(blobs *cas.Store, ref cas.Ref) ([]byte, error) {
path, err := blobs.Path(ref)
if err != nil {
return nil, err
}
return os.ReadFile(path)
}
// stagedName keeps the person's own filename and appends a short content
// suffix, so two files called report.pdf stay distinct and one file staged by
// several leaves of the same job lands on one already-complete path.
func stagedName(name string, data []byte) string {
extension := filepath.Ext(name)
stem := strings.TrimSuffix(name, extension)
if stem == "" {
stem = "attachment"
}
digest := cas.DigestBytes(data).String()
return stem + "-" + digest[:8] + extension
}
func writeStagedFile(target string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
file, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
if os.IsExist(err) {
return nil
}
return err
}
if _, err := file.Write(data); err != nil {
_ = file.Close()
return err
}
return file.Close()
}