Skip to content
Open
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
67 changes: 60 additions & 7 deletions go/cmd/amikalog/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,33 @@ import (
"github.com/spf13/cobra"
)

// uploadMemories opts into uploading Claude memory files alongside captured
// events. allMemoryProjects extends that to projects with no captured session.
var (
uploadMemories bool
allMemoryProjects bool
)

var pushCmd = &cobra.Command{
Use: "beta:push",
Short: "Upload captured events to your organization",
Long: `Upload captured events that have not been pushed yet. Repeated runs upload
only events captured since the last push.

Pass --memories to also upload Claude memory files
(~/.claude/projects/<project>/memory/*.md) for the projects you have captured
sessions for. Because memory files are edited in place, a file that changed both
locally and in the cloud is merged with the local claude CLI rather than
overwritten. Add --all-projects to include projects with no captured session.

Set AMIKA_API_KEY to authenticate.`,
Args: cobra.NoArgs,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
if allMemoryProjects && !uploadMemories {
return fmt.Errorf("--all-projects requires --memories")
}
key := os.Getenv(config.EnvAPIKey)
if key == "" {
return fmt.Errorf("set %s to push; amikalog authenticates with an org API key only", config.EnvAPIKey)
Expand All @@ -31,18 +47,42 @@ Set AMIKA_API_KEY to authenticate.`,
}

client := apiclient.NewClientWithTokenSource(config.APIURL(), apiclient.NewStaticTokenSource(key))
report, err := eventlog.Push(stateDir, apiUploader{client: client})
uploader := apiUploader{client: client}
out := cmd.OutOrStdout()
errOut := cmd.ErrOrStderr()

report, err := eventlog.Push(stateDir, uploader)
if err != nil {
return err
}

out := cmd.OutOrStdout()
fmt.Fprintf(out, "uploaded %d, skipped %d, failed %d\n", report.Uploaded, report.Skipped, report.Failed)
if report.Failed > 0 {
for _, e := range report.Errors {
fmt.Fprintf(cmd.ErrOrStderr(), "amikalog: %v\n", e)
for _, e := range report.Errors {
fmt.Fprintf(errOut, "amikalog: %v\n", e)
}
failed := report.Failed

if uploadMemories {
home, herr := os.UserHomeDir()
if herr != nil {
return fmt.Errorf("resolving home directory: %w", herr)
}
return fmt.Errorf("%d file(s) failed to upload", report.Failed)
mreport, merr := eventlog.PushMemories(stateDir, home, allMemoryProjects, uploader, apiDownloader{client: client}, eventlog.NewClaudeMerger())
if merr != nil {
return fmt.Errorf("pushing memories: %w", merr)
}
fmt.Fprintf(out, "memories: uploaded %d, merged %d, pulled %d, skipped %d, failed %d\n",
mreport.Uploaded, mreport.Merged, mreport.Pulled, mreport.Skipped, mreport.Failed)
for _, w := range mreport.Warnings {
fmt.Fprintf(errOut, "amikalog: %s\n", w)
}
for _, e := range mreport.Errors {
fmt.Fprintf(errOut, "amikalog: %v\n", e)
}
failed += mreport.Failed
}

if failed > 0 {
return fmt.Errorf("%d file(s) failed to upload", failed)
}
return nil
},
Expand Down Expand Up @@ -72,6 +112,19 @@ func (a apiUploader) Upload(objectKey string, data []byte) error {
return a.client.UploadToSignedURL(resp.Objects[0].UploadURL, data, "application/json")
}

// apiDownloader adapts the Amika API client to eventlog.Downloader: it fetches a
// single object's current bytes by key, used to detect whether a memory file's
// cloud copy has diverged before overwriting it.
type apiDownloader struct {
client *apiclient.Client
}

func (a apiDownloader) Fetch(objectKey string) ([]byte, bool, error) {
return a.client.GetObjectByKey(objectKey)
}

func init() {
pushCmd.Flags().BoolVar(&uploadMemories, "memories", false, "Also upload Claude memory files for projects with captured sessions")
pushCmd.Flags().BoolVar(&allMemoryProjects, "all-projects", false, "With --memories, include projects that have no captured session")
rootCmd.AddCommand(pushCmd)
}
38 changes: 38 additions & 0 deletions go/internal/apiclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,44 @@ func (c *Client) DownloadFromSignedURL(signedURL string) ([]byte, error) {
return body, nil
}

// GetObjectByKey fetches the current bytes of a single object by its exact
// bucket key. There is no single-object endpoint, so it lists the object's
// parent folder and downloads the entry whose Key matches exactly. found is
// false when no object has that exact key, which the caller can treat as "no
// cloud copy yet".
//
// The listing is restricted to the parent prefix (everything up to and
// including the final "/") rather than the full key: the storage backend treats
// a listing prefix as a folder path, so a prefix equal to the full object key
// (filename included) matches nothing. Listing the folder and exact-matching
// within it is the only reliable way to find the object.
func (c *Client) GetObjectByKey(key string) (data []byte, found bool, err error) {
prefix := ""
if i := strings.LastIndex(key, "/"); i >= 0 {
prefix = key[:i+1]
}
cursor := ""
for {
resp, err := c.ListDownloads(prefix, cursor, 0)
if err != nil {
return nil, false, err
}
for _, o := range resp.Objects {
if o.Key == key {
b, err := c.DownloadFromSignedURL(o.DownloadURL)
if err != nil {
return nil, false, err
}
return b, true, nil
}
}
if resp.NextCursor == nil || *resp.NextCursor == "" {
return nil, false, nil
}
cursor = *resp.NextCursor
}
}

func (c *Client) doJSON(method, path string, body interface{}, out interface{}) error {
var bodyReader io.Reader
if body != nil {
Expand Down
122 changes: 122 additions & 0 deletions go/internal/apiclient/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,3 +466,125 @@ func TestDownloadFromSignedURL_Non2xxIsHTTPError(t *testing.T) {
t.Errorf("status = %d, want 404", httpErr.StatusCode)
}
}

// folderPrefixBucket is an httptest server that mimics the storage backend's
// listing semantics: it treats the `prefix` query as a FOLDER path, so it
// returns an object only when the prefix is empty or ends in "/". A prefix equal
// to a full object key (filename included, no trailing slash) matches nothing —
// the exact behavior that made GetObjectByKey re-upload every memory file when
// it listed by the full key. Each object's download_url points back at this same
// server so the returned bytes can be fetched.
func folderPrefixBucket(t *testing.T, objects map[string]string) *httptest.Server {
t.Helper()
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v0beta1/storage/downloads":
prefix := r.URL.Query().Get("prefix")
out := []map[string]any{}
for key := range objects {
// Folder semantics: only an empty or "/"-terminated prefix lists
// objects; a full-key prefix matches nothing.
if prefix != "" && !strings.HasSuffix(prefix, "/") {
continue
}
if !strings.HasPrefix(key, prefix) {
continue
}
out = append(out, map[string]any{
"key": key,
"size": len(objects[key]),
"last_modified": "2026-01-01T00:00:00Z",
"download_url": srv.URL + "/dl?key=" + url.QueryEscape(key),
})
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"bucket": "org-123",
"prefix": prefix,
"objects": out,
"expires_in": 3600,
"next_cursor": nil,
})
case "/dl":
body, ok := objects[r.URL.Query().Get("key")]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv
}

// TestGetObjectByKey_FindsObjectViaFolderPrefix is the regression test for the
// memory re-upload bug: against a backend that only honors folder prefixes,
// GetObjectByKey must still find an object by listing its parent folder and
// exact-matching the key, rather than listing by the full key (which returns
// nothing). It also asserts the prefix actually sent is the folder, not the key.
func TestGetObjectByKey_FindsObjectViaFolderPrefix(t *testing.T) {
objects := map[string]string{
"decyph-ai-app/memory/memory.md": `{"a":1}`,
// A sibling that shares the folder, to ensure exact-match (not just
// prefix-match) selects the right object.
"decyph-ai-app/memory/memory.md.bak": `{"a":2}`,
}
srv := folderPrefixBucket(t, objects)

var gotPrefix string
c := NewClient(srv.URL, "key-xyz")
c.HTTP = recordPrefixClient(&gotPrefix)

data, found, err := c.GetObjectByKey("decyph-ai-app/memory/memory.md")
if err != nil {
t.Fatalf("GetObjectByKey: %v", err)
}
if !found {
t.Fatal("found = false, want true (object exists under the folder)")
}
if string(data) != `{"a":1}` {
t.Errorf("data = %q, want %q", string(data), `{"a":1}`)
}
if gotPrefix != "decyph-ai-app/memory/" {
t.Errorf("listing prefix = %q, want the parent folder %q", gotPrefix, "decyph-ai-app/memory/")
}
}

// TestGetObjectByKey_NotFound confirms a genuinely absent object reports found
// = false (so callers treat it as "no cloud copy yet"), even though the folder
// listing returns sibling objects.
func TestGetObjectByKey_NotFound(t *testing.T) {
srv := folderPrefixBucket(t, map[string]string{
"decyph-ai-app/memory/memory.md": `{"a":1}`,
})
c := NewClient(srv.URL, "key-xyz")

data, found, err := c.GetObjectByKey("decyph-ai-app/memory/absent.md")
if err != nil {
t.Fatalf("GetObjectByKey: %v", err)
}
if found {
t.Errorf("found = true, want false for an absent key (data %q)", string(data))
}
}

// recordPrefixClient returns an *http.Client whose transport records the latest
// `prefix` query parameter seen on a downloads listing request before forwarding
// it unchanged, letting a test assert which prefix GetObjectByKey listed by.
func recordPrefixClient(prefix *string) *http.Client {
return &http.Client{Transport: prefixRecorder{prefix: prefix}}
}

type prefixRecorder struct{ prefix *string }

func (p prefixRecorder) RoundTrip(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/api/v0beta1/storage/downloads" {
*p.prefix = r.URL.Query().Get("prefix")
}
return http.DefaultTransport.RoundTrip(r)
}
Loading