Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ Notably:

## What are Cloudflare Artifacts?

[Cloudflare Artifacts](https://workers.cloudflare.com/product/artifacts) is a versioned filesystem that speaks git. Create a repo per user, per agent session, per sandbox... as many as you need.
[Cloudflare Artifacts](https://workers.cloudflare.com/product/artifacts) is a versioned filesystem that speaks git. Create a repo per user, per agent session, per sandbox... as many as you need. It's designed for agent toolchains, sandboxes, and CI/CD systems that need fast, scalable access to code repositories.

It's designed for agent toolchains, sandboxes, and CI/CD systems that need fast, scalable access to code repositories. ArtifactFS is the optional FUSE driver -- it lets you mount an Artifact (or any git repo) as a local filesystem without waiting for a full clone.
ArtifactFS is the optional FUSE driver -- it lets you mount an Artifact (or any git repo) as a local filesystem without waiting for a full clone.

## Build and Install

Expand Down
27 changes: 23 additions & 4 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,7 @@ func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer)
if err != nil {
return err
}
fmt.Fprintf(w, "repo=%s state=%s head=%s ref=%s ahead=%d behind=%d diverged=%t last_fetch=%s result=%s overlay_dirty=%t\n",
st.RepoID, st.State, st.CurrentHEADOID, st.CurrentHEADRef,
st.AheadCount, st.BehindCount, st.Diverged,
st.LastFetchAt.Format(time.RFC3339), st.LastFetchResult, st.DirtyOverlay)
fmt.Fprintln(w, formatStatusLine(st))
return nil
}),
nameCommand("fetch", "fetch remote updates", ctx, root, stderr, stdout, func(c context.Context, svc *daemon.Service, name string, w io.Writer) error {
Expand Down Expand Up @@ -218,6 +215,28 @@ func nameCommand(name, usage string, ctx context.Context, root string, stderr io
}
}

func formatStatusLine(st model.RepoRuntimeState) string {
return fmt.Sprintf("repo=%s state=%s head=%s ref=%s ahead=%d behind=%d diverged=%t last_fetch=%s result=%s hydrated_blobs=%d hydrated_bytes=%d overlay_dirty=%t",
st.RepoID, st.State, st.CurrentHEADOID, st.CurrentHEADRef,
st.AheadCount, st.BehindCount, st.Diverged,
formatLastFetchAt(st.LastFetchAt), formatLastFetchResult(st.LastFetchResult),
st.HydratedBlobCount, st.HydratedBlobBytes, st.DirtyOverlay)
}

func formatLastFetchAt(at time.Time) string {
if at.IsZero() {
return "never"
}
return at.Format(time.RFC3339)
}

func formatLastFetchResult(result string) string {
if strings.TrimSpace(result) == "" {
return "never"
}
return result
}

func stubCommand(name, usage string, stdout io.Writer) ucli.Command {
return ucli.Command{
Name: name,
Expand Down
46 changes: 46 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package cli

import (
"strings"
"testing"
"time"

"github.com/cloudflare/artifact-fs/internal/model"
)

func TestFormatStatusLineUsesNeverForUnsetFetch(t *testing.T) {
st := model.RepoRuntimeState{
RepoID: "workerd",
State: "mounted",
CurrentHEADOID: "abc123",
CurrentHEADRef: "main",
LastFetchResult: "never",
HydratedBlobCount: 3,
HydratedBlobBytes: 42,
}

got := formatStatusLine(st)
for _, want := range []string{
"last_fetch=never",
"result=never",
"hydrated_blobs=3",
"hydrated_bytes=42",
} {
if !strings.Contains(got, want) {
t.Fatalf("status line %q missing %q", got, want)
}
}
if strings.Contains(got, "0001-01-01T00:00:00Z") {
t.Fatalf("status line leaked zero time: %q", got)
}
}

func TestFormatStatusLineFormatsFetchTimestamp(t *testing.T) {
at := time.Date(2026, time.March, 31, 12, 34, 56, 0, time.UTC)
st := model.RepoRuntimeState{LastFetchAt: at, LastFetchResult: "ok"}

got := formatStatusLine(st)
if !strings.Contains(got, "last_fetch=2026-03-31T12:34:56Z") {
t.Fatalf("status line %q missing formatted timestamp", got)
}
}
40 changes: 39 additions & 1 deletion internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
Expand Down Expand Up @@ -257,7 +258,9 @@ func (s *Service) Status(ctx context.Context, name string) (model.RepoRuntimeSta
dirty, _ := rt.overlay.DirtyCount(ctx)
rt.state.DirtyOverlay = dirty > 0
st := rt.state // copy under lock
cfg = rt.cfg
s.mu.Unlock()
applyHydrationStats(&st, cfg.BlobCacheDir)
return st, nil
}
s.mu.Unlock()
Expand Down Expand Up @@ -477,7 +480,7 @@ func (s *Service) refreshLoop(rt *repoRuntime) {
func (s *Service) readPersistedStatus(ctx context.Context, cfg model.RepoConfig) model.RepoRuntimeState {
// One-shot CLI process: reconstruct state from persisted stores and
// OS-level mount check since we don't share memory with the daemon.
st := model.RepoRuntimeState{RepoID: cfg.ID, State: "unmounted"}
st := model.RepoRuntimeState{RepoID: cfg.ID, State: "unmounted", LastFetchResult: "never"}
if isMounted(cfg.MountPath) {
st.State = "mounted"
}
Expand All @@ -503,6 +506,7 @@ func (s *Service) readPersistedStatus(ctx context.Context, cfg model.RepoConfig)
st.LastFetchAt = fi.ModTime()
st.LastFetchResult = "ok"
}
applyHydrationStats(&st, cfg.BlobCacheDir)
return st
}

Expand Down Expand Up @@ -570,6 +574,7 @@ func newRuntimeState(repoID model.RepoID, headOID string, headRef string, gen in
CurrentHEADOID: headOID,
CurrentHEADRef: headRef,
SnapshotGeneration: gen,
LastFetchResult: "never",
State: "ready",
}
}
Expand Down Expand Up @@ -604,6 +609,39 @@ func markFetchFailure(st *model.RepoRuntimeState, result string) {
st.LastFetchResult = result
}

func applyHydrationStats(st *model.RepoRuntimeState, cacheDir string) {
count, bytes := blobCacheStats(cacheDir)
st.HydratedBlobCount = count
st.HydratedBlobBytes = bytes
}

func blobCacheStats(cacheDir string) (int64, int64) {
if strings.TrimSpace(cacheDir) == "" {
return 0, 0
}
var count int64
var bytes int64
_ = filepath.WalkDir(cacheDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
info, statErr := d.Info()
if statErr != nil {
return nil
}
if !info.Mode().IsRegular() {
return nil
}
count++
bytes += info.Size()
return nil
})
return count, bytes
}

func (s *Service) unmount(id model.RepoID) {
s.mu.Lock()
defer s.mu.Unlock()
Expand Down
48 changes: 48 additions & 0 deletions internal/daemon/status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package daemon

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/cloudflare/artifact-fs/internal/model"
)

func TestReadPersistedStatusIncludesHydrationStats(t *testing.T) {
t.Helper()
root := t.TempDir()
cacheDir := filepath.Join(root, "cache")
gitDir := filepath.Join(root, "git")
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
t.Fatalf("mkdir cache: %v", err)
}
if err := os.MkdirAll(gitDir, 0o755); err != nil {
t.Fatalf("mkdir git: %v", err)
}
if err := os.WriteFile(filepath.Join(cacheDir, "blob-a"), []byte("abc"), 0o644); err != nil {
t.Fatalf("write blob-a: %v", err)
}
if err := os.MkdirAll(filepath.Join(cacheDir, "nested"), 0o755); err != nil {
t.Fatalf("mkdir nested: %v", err)
}
if err := os.WriteFile(filepath.Join(cacheDir, "nested", "blob-b"), []byte("hello"), 0o644); err != nil {
t.Fatalf("write blob-b: %v", err)
}

svc := &Service{}
st := svc.readPersistedStatus(context.Background(), model.RepoConfig{ID: "repo", BlobCacheDir: cacheDir, GitDir: gitDir})

if st.LastFetchResult != "never" {
t.Fatalf("LastFetchResult = %q, want never", st.LastFetchResult)
}
if !st.LastFetchAt.IsZero() {
t.Fatalf("LastFetchAt = %v, want zero", st.LastFetchAt)
}
if st.HydratedBlobCount != 2 {
t.Fatalf("HydratedBlobCount = %d, want 2", st.HydratedBlobCount)
}
if st.HydratedBlobBytes != 8 {
t.Fatalf("HydratedBlobBytes = %d, want 8", st.HydratedBlobBytes)
}
}
2 changes: 2 additions & 0 deletions internal/model/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ type RepoRuntimeState struct {
AheadCount int
BehindCount int
Diverged bool
HydratedBlobCount int64
HydratedBlobBytes int64
DirtyOverlay bool
State string
}
Expand Down
Loading