Skip to content

Commit 5d64095

Browse files
ysyneuclaude
andcommitted
feat(knowledge): stage_knowledge_files and delete_knowledge_files task handlers
Implements the Safari-side contract documented in fc-safari's docs/knowledge-runner-tasks.md. Atomic writes via temp+rename; sentinel rewrites under flock to handle concurrent BYOC sessions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c23cd4d commit 5d64095

4 files changed

Lines changed: 540 additions & 3 deletions

File tree

protocol/messages.go

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,11 @@ const (
108108
TaskOpGrep TaskOperation = "grep"
109109
TaskOpBash TaskOperation = "bash"
110110
TaskOpWebFetch TaskOperation = "webfetch"
111-
TaskOpMCPCall TaskOperation = "mcp_call"
112-
TaskOpMCPListTools TaskOperation = "mcp_list_tools"
113-
TaskOpSyncSkill TaskOperation = "sync_skill"
111+
TaskOpMCPCall TaskOperation = "mcp_call"
112+
TaskOpMCPListTools TaskOperation = "mcp_list_tools"
113+
TaskOpSyncSkill TaskOperation = "sync_skill"
114+
TaskOpStageKnowledgeFiles TaskOperation = "stage_knowledge_files"
115+
TaskOpDeleteKnowledgeFiles TaskOperation = "delete_knowledge_files"
114116
)
115117

116118
// TaskRequestPayload is the payload for task request messages.
@@ -323,3 +325,37 @@ type MCPResultPayload struct {
323325
Result json.RawMessage `json:"result,omitempty"`
324326
Error string `json:"error,omitempty"`
325327
}
328+
329+
// KnowledgeFile is a single file entry in a stage_knowledge_files request.
330+
type KnowledgeFile struct {
331+
RelPath string `json:"rel_path"`
332+
Checksum string `json:"checksum"`
333+
ContentB64 string `json:"content_b64"`
334+
}
335+
336+
// StageKnowledgeFilesArgs are the arguments for stage_knowledge_files operation.
337+
type StageKnowledgeFilesArgs struct {
338+
Files []KnowledgeFile `json:"files"`
339+
}
340+
341+
// KnowledgeFileStatus is the per-file result entry in the stage ack.
342+
type KnowledgeFileStatus struct {
343+
RelPath string `json:"rel_path"`
344+
Success bool `json:"success"`
345+
Error string `json:"error,omitempty"`
346+
}
347+
348+
// StageKnowledgeFilesResult is the result of a stage_knowledge_files operation.
349+
type StageKnowledgeFilesResult struct {
350+
Files []KnowledgeFileStatus `json:"files"`
351+
}
352+
353+
// DeleteKnowledgeFilesArgs are the arguments for delete_knowledge_files operation.
354+
type DeleteKnowledgeFilesArgs struct {
355+
RelPaths []string `json:"rel_paths"`
356+
}
357+
358+
// DeleteKnowledgeFilesResult is the result of a delete_knowledge_files operation.
359+
type DeleteKnowledgeFilesResult struct {
360+
Success bool `json:"success"`
361+
}

workspace/knowledge.go

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
package workspace
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
"fmt"
8+
"log/slog"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
"syscall"
13+
14+
"github.com/flashcatcloud/flashduty-runner/protocol"
15+
)
16+
17+
const (
18+
// sentinelName is the hidden JSON map that tracks staged-file checksums.
19+
// Safari reads this to decide which knowledge pack files are already current.
20+
sentinelName = ".safari-knowledge-sentinel.json"
21+
)
22+
23+
// validateKnowledgeRelPath enforces the path rules for knowledge file operations.
24+
//
25+
// Rules (from the Safari-side contract):
26+
// - Must not contain path separators or double-dot components — the runner
27+
// only writes flat files in the workspace root, never in sub-directories.
28+
// - Leading-dot filenames are rejected because they are hidden by convention;
29+
// the sentinel is written by the runner itself and is never staged by clients.
30+
func validateKnowledgeRelPath(relPath string) error {
31+
if strings.ContainsAny(relPath, `/\`) {
32+
return fmt.Errorf("rel_path must not contain path separators: %q", relPath)
33+
}
34+
if relPath == ".." || strings.Contains(relPath, "..") {
35+
return fmt.Errorf("rel_path must not contain '..': %q", relPath)
36+
}
37+
if strings.HasPrefix(relPath, ".") {
38+
// Hidden files (including the sentinel itself) cannot be staged by clients.
39+
// The runner owns the sentinel exclusively.
40+
return fmt.Errorf("rel_path must not start with '.': %q", relPath)
41+
}
42+
if relPath == "" {
43+
return fmt.Errorf("rel_path must not be empty")
44+
}
45+
return nil
46+
}
47+
48+
// atomicWriteFile writes data to path using a temp-file + rename so readers
49+
// never see a partially-written file. The temp file is created in the same
50+
// directory as the target to guarantee the rename stays on the same filesystem.
51+
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
52+
dir := filepath.Dir(path)
53+
tmp, err := os.CreateTemp(dir, ".tmp-knowledge-*")
54+
if err != nil {
55+
return fmt.Errorf("failed to create temp file: %w", err)
56+
}
57+
tmpName := tmp.Name()
58+
59+
// Clean up the temp file on any error path.
60+
var writeErr error
61+
defer func() {
62+
if writeErr != nil {
63+
_ = os.Remove(tmpName)
64+
}
65+
}()
66+
67+
if _, writeErr = tmp.Write(data); writeErr != nil {
68+
_ = tmp.Close()
69+
return fmt.Errorf("failed to write temp file: %w", writeErr)
70+
}
71+
if writeErr = tmp.Chmod(perm); writeErr != nil {
72+
_ = tmp.Close()
73+
return fmt.Errorf("failed to chmod temp file: %w", writeErr)
74+
}
75+
if writeErr = tmp.Close(); writeErr != nil {
76+
return fmt.Errorf("failed to close temp file: %w", writeErr)
77+
}
78+
79+
if writeErr = os.Rename(tmpName, path); writeErr != nil {
80+
return fmt.Errorf("failed to rename temp file: %w", writeErr)
81+
}
82+
return nil
83+
}
84+
85+
// withSentinelLock opens (or creates) the sentinel file, acquires an exclusive
86+
// advisory flock on it, calls fn, then releases the lock. The advisory lock
87+
// protects concurrent read-modify-write cycles across BYOC sessions that share
88+
// the same filesystem (e.g. multiple Safari instances writing to the same
89+
// worknode workspace root).
90+
//
91+
// Note: syscall.Flock is available on Linux and macOS. The runner is deployed
92+
// on those platforms only; Windows is not supported today.
93+
func withSentinelLock(sentinelPath string, fn func() error) error {
94+
// Open or create the sentinel file just to acquire the lock fd.
95+
// We do NOT read/write through this fd to keep flock + atomic-write
96+
// concerns separate.
97+
lockFile, err := os.OpenFile(sentinelPath, os.O_RDWR|os.O_CREATE, 0o644)
98+
if err != nil {
99+
return fmt.Errorf("failed to open sentinel for locking: %w", err)
100+
}
101+
defer func() {
102+
_ = lockFile.Close()
103+
}()
104+
105+
if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil {
106+
return fmt.Errorf("failed to acquire sentinel lock: %w", err)
107+
}
108+
defer func() {
109+
_ = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN)
110+
}()
111+
112+
return fn()
113+
}
114+
115+
// readSentinel reads the sentinel JSON map. Missing file or empty file →
116+
// empty map (both are expected states on first use). Corrupt JSON → log a
117+
// warning, return empty map (safe rebuild on next stage).
118+
func readSentinel(sentinelPath string) map[string]string {
119+
data, err := os.ReadFile(sentinelPath)
120+
if err != nil {
121+
if !os.IsNotExist(err) {
122+
slog.Warn("failed to read sentinel, treating as empty", "error", err)
123+
}
124+
return make(map[string]string)
125+
}
126+
// An empty file is the initial state created by withSentinelLock; treat it
127+
// as an empty map without logging a warning.
128+
if len(data) == 0 {
129+
return make(map[string]string)
130+
}
131+
var m map[string]string
132+
if err := json.Unmarshal(data, &m); err != nil {
133+
slog.Warn("sentinel JSON corrupt, treating as empty", "error", err)
134+
return make(map[string]string)
135+
}
136+
return m
137+
}
138+
139+
// writeSentinel atomically rewrites the sentinel with the given map.
140+
func writeSentinel(sentinelPath string, m map[string]string) error {
141+
data, err := json.Marshal(m)
142+
if err != nil {
143+
return fmt.Errorf("failed to marshal sentinel: %w", err)
144+
}
145+
return atomicWriteFile(sentinelPath, data, 0o644)
146+
}
147+
148+
// StageKnowledgeFiles writes the supplied files into the workspace root and
149+
// updates the sentinel checksum map.
150+
func (w *Workspace) StageKnowledgeFiles(ctx context.Context, args *protocol.StageKnowledgeFilesArgs) (*protocol.StageKnowledgeFilesResult, error) {
151+
result := &protocol.StageKnowledgeFilesResult{
152+
Files: make([]protocol.KnowledgeFileStatus, 0, len(args.Files)),
153+
}
154+
155+
// validated collects (relPath, checksum) for files that landed successfully;
156+
// we only merge these into the sentinel.
157+
type staged struct{ relPath, checksum string }
158+
var succeeded []staged
159+
160+
for _, f := range args.Files {
161+
status := protocol.KnowledgeFileStatus{RelPath: f.RelPath}
162+
163+
if err := validateKnowledgeRelPath(f.RelPath); err != nil {
164+
status.Success = false
165+
status.Error = err.Error()
166+
result.Files = append(result.Files, status)
167+
continue
168+
}
169+
170+
content, err := base64.StdEncoding.DecodeString(f.ContentB64)
171+
if err != nil {
172+
status.Success = false
173+
status.Error = fmt.Sprintf("failed to decode content_b64: %v", err)
174+
result.Files = append(result.Files, status)
175+
continue
176+
}
177+
178+
targetPath := filepath.Join(w.root, f.RelPath)
179+
if err := atomicWriteFile(targetPath, content, 0o644); err != nil {
180+
status.Success = false
181+
status.Error = err.Error()
182+
result.Files = append(result.Files, status)
183+
continue
184+
}
185+
186+
status.Success = true
187+
result.Files = append(result.Files, status)
188+
succeeded = append(succeeded, staged{f.RelPath, f.Checksum})
189+
}
190+
191+
// Update sentinel for successfully written files under advisory lock.
192+
if len(succeeded) > 0 {
193+
sentinelPath := filepath.Join(w.root, sentinelName)
194+
if err := withSentinelLock(sentinelPath, func() error {
195+
m := readSentinel(sentinelPath)
196+
for _, s := range succeeded {
197+
m[s.relPath] = s.checksum
198+
}
199+
return writeSentinel(sentinelPath, m)
200+
}); err != nil {
201+
// Sentinel write failure is non-fatal for the already-written files,
202+
// but we log it clearly so operators can investigate.
203+
slog.Error("failed to update sentinel after staging", "error", err)
204+
}
205+
}
206+
207+
return result, nil
208+
}
209+
210+
// DeleteKnowledgeFiles removes the supplied files from the workspace root and
211+
// scrubs their entries from the sentinel.
212+
func (w *Workspace) DeleteKnowledgeFiles(ctx context.Context, args *protocol.DeleteKnowledgeFilesArgs) (*protocol.DeleteKnowledgeFilesResult, error) {
213+
var removed []string
214+
215+
for _, relPath := range args.RelPaths {
216+
if err := validateKnowledgeRelPath(relPath); err != nil {
217+
slog.Warn("skipping invalid rel_path in delete_knowledge_files", "rel_path", relPath, "error", err)
218+
continue
219+
}
220+
221+
targetPath := filepath.Join(w.root, relPath)
222+
if err := os.Remove(targetPath); err != nil && !os.IsNotExist(err) {
223+
slog.Warn("failed to remove knowledge file", "rel_path", relPath, "error", err)
224+
continue
225+
}
226+
removed = append(removed, relPath)
227+
}
228+
229+
// Remove entries from sentinel for files we attempted to delete (whether
230+
// they existed or not — idempotent means we clean the sentinel too).
231+
sentinelPath := filepath.Join(w.root, sentinelName)
232+
toRemove := make(map[string]struct{}, len(args.RelPaths))
233+
for _, rp := range args.RelPaths {
234+
if validateKnowledgeRelPath(rp) == nil {
235+
toRemove[rp] = struct{}{}
236+
}
237+
}
238+
239+
if len(toRemove) > 0 {
240+
if err := withSentinelLock(sentinelPath, func() error {
241+
m := readSentinel(sentinelPath)
242+
for rp := range toRemove {
243+
delete(m, rp)
244+
}
245+
return writeSentinel(sentinelPath, m)
246+
}); err != nil {
247+
slog.Error("failed to update sentinel after deletion", "error", err)
248+
}
249+
}
250+
251+
_ = removed // logged individually above; all valid paths are processed
252+
return &protocol.DeleteKnowledgeFilesResult{Success: true}, nil
253+
}

0 commit comments

Comments
 (0)