mAi: #7 - cloud-sync to Supabase Storage + imagen.images
Every successful imagen generate now (a) uploads the PNG to the private imagen-generated bucket and (b) inserts a row into imagen.images, the data plane the flexsiebels owner-mode viewer reads from. Schema, RLS, indexes, bucket and PostgREST exposure landed via four applied migrations on msupabase: imagen_schema_init, imagen_schema_grants, imagen_storage_policies, imagen_pgrst_expose (authenticator role-level ALTER + reload). Owner UUID for m: ac6c9501-3757-4a6d-8b97-2cff4288382b — documented in the config sample. Code: new internal/cloud/ package mirroring the internal/usage/ shape. PostgREST POST against the imagen schema (Accept-Profile + Content- Profile headers), Storage upload via PUT with x-upsert, retry on 5xx / transport but not 4xx, owner_user_id required (the column is NOT NULL and the read-side RLS policy needs it). Wiring in cmd/imagen/generate.go: --no-cloud flag, output.cloud_sync config knob (auto|on|off mirroring --preview), $IMAGEN_CLOUD_SYNC env override. The hook reads the just-written PNG + sidecar from disk and calls cloud.Sync; failures emit "imagen: cloud sync: <err>" to stderr without changing exit code, so a Supabase blip never loses the artefact. output.Outputs grew Date/Slug/Seed fields so storage_path mirrors the local filename's prefix exactly (no UTC-vs-local drift). Config: owner_user_id field added; sample comment points at the auth.users lookup. imagen config validate warns on stderr when cloud_sync is on/auto but owner_user_id is empty. Tests: cloud_test.go covers happy path, retry-on-5xx, no-retry-on-4xx, missing-owner-uuid, missing-date-or-slug, signed URL, and the partial- success case where the upload landed but the DB insert failed. generate_test.go covers the precedence chain for cloud-sync mode resolution. Build + tests clean across the tree. Real smoke against mRock: generation through flux-schnell-local writes the local PNG + sidecar AND uploads to imagen-generated/2026-05-11/... AND inserts into imagen.images. Signed URL round-trips the same bytes. --no-cloud verified to skip both Storage and DB.
This commit is contained in:
@@ -39,6 +39,18 @@ func runConfig(args []string) error {
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "OK — %d backend(s) defined, default=%q\n",
|
||||
len(cfg.Backends), cfg.DefaultBackend)
|
||||
// Soft warnings — surfaced on stderr so they're visible but don't
|
||||
// fail the validate exit code.
|
||||
cloudMode := cfg.Output.CloudSync
|
||||
if cloudMode == "" {
|
||||
cloudMode = "auto"
|
||||
}
|
||||
if cloudMode != "off" && cfg.OwnerUserID == "" {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
"warning: cloud_sync is "+cloudMode+" but owner_user_id is empty — DB inserts will be skipped.")
|
||||
fmt.Fprintln(os.Stderr,
|
||||
" look it up: SELECT id FROM auth.users WHERE email = '<your-supabase-email>';")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return userErr("unknown config subcommand %q (init|validate|path)", args[0])
|
||||
|
||||
@@ -2,13 +2,17 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/ImaGen/internal/backend"
|
||||
"mgit.msbls.de/m/ImaGen/internal/cloud"
|
||||
"mgit.msbls.de/m/ImaGen/internal/config"
|
||||
"mgit.msbls.de/m/ImaGen/internal/output"
|
||||
"mgit.msbls.de/m/ImaGen/internal/preview"
|
||||
@@ -30,6 +34,7 @@ func runGenerate(ctx context.Context, args []string) error {
|
||||
noSidecar bool
|
||||
previewOn bool
|
||||
previewOff bool
|
||||
noCloud bool
|
||||
)
|
||||
fs.StringVar(&backendName, "backend", "", "backend instance name (default: config.default_backend)")
|
||||
fs.StringVar(&size, "size", "1024x1024", "WxH, e.g. 1024x1024")
|
||||
@@ -42,6 +47,7 @@ func runGenerate(ctx context.Context, args []string) error {
|
||||
fs.BoolVar(&noSidecar, "no-sidecar", false, "skip the JSON sidecar even if config enables it")
|
||||
fs.BoolVar(&previewOn, "preview", false, "force tmux preview window on (errors outside $TMUX)")
|
||||
fs.BoolVar(&previewOff, "no-preview", false, "skip the tmux preview window")
|
||||
fs.BoolVar(&noCloud, "no-cloud", false, "skip Supabase upload + imagen.images insert for this generation")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintln(fs.Output(), `Usage: imagen generate "<prompt>" [flags]`)
|
||||
fs.PrintDefaults()
|
||||
@@ -126,6 +132,11 @@ func runGenerate(ctx context.Context, args []string) error {
|
||||
fmt.Fprintln(os.Stderr, "sidecar:", paths.SidecarPath)
|
||||
}
|
||||
|
||||
if err := maybeCloudSync(ctx, cfg, noCloud, paths, in, res, w, h); err != nil {
|
||||
// cloud-sync failures are warnings — the image already wrote.
|
||||
fmt.Fprintln(os.Stderr, "imagen: cloud sync:", err)
|
||||
}
|
||||
|
||||
if err := maybePreview(cfg, previewOn, previewOff, paths.ImagePath, rawPrompt); err != nil {
|
||||
// preview failures are warnings — the image already wrote.
|
||||
fmt.Fprintln(os.Stderr, "imagen: preview:", err)
|
||||
@@ -133,6 +144,175 @@ func runGenerate(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveCloudSyncMode applies the precedence chain config -> env -> flag.
|
||||
// Flags win, env beats config, config beats the implicit auto default.
|
||||
// Mirrors resolvePreviewMode shape.
|
||||
func resolveCloudSyncMode(cfg *config.Config, noCloudFlag bool, env string) (string, error) {
|
||||
mode := "auto"
|
||||
if cfg != nil && cfg.Output.CloudSync != "" {
|
||||
mode = cfg.Output.CloudSync
|
||||
}
|
||||
if env != "" {
|
||||
switch env {
|
||||
case "auto", "on", "off":
|
||||
mode = env
|
||||
default:
|
||||
return "", fmt.Errorf("$IMAGEN_CLOUD_SYNC = %q (must be auto|on|off)", env)
|
||||
}
|
||||
}
|
||||
if noCloudFlag {
|
||||
mode = "off"
|
||||
}
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
// maybeCloudSync resolves the effective mode and, if it says yes, uploads
|
||||
// the PNG and inserts the row. Always non-fatal — the image already wrote.
|
||||
func maybeCloudSync(ctx context.Context, cfg *config.Config, noCloud bool, paths *output.Outputs, in output.Inputs, res *backend.Result, width, height int) error {
|
||||
mode, err := resolveCloudSyncMode(cfg, noCloud, os.Getenv("IMAGEN_CLOUD_SYNC"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mode == "off" {
|
||||
return nil
|
||||
}
|
||||
|
||||
sink, ok := cloud.NewFromEnv()
|
||||
if !ok {
|
||||
if mode == "on" {
|
||||
return fmt.Errorf("cloud_sync=on but SUPABASE_URL / SUPABASE_SERVICE_KEY not set in env")
|
||||
}
|
||||
// auto + missing env = silent skip.
|
||||
return nil
|
||||
}
|
||||
// Config-supplied owner_user_id takes precedence over $IMAGEN_OWNER_USER_ID.
|
||||
if cfg != nil && cfg.OwnerUserID != "" {
|
||||
sink.OwnerUserID = cfg.OwnerUserID
|
||||
}
|
||||
if sink.OwnerUserID == "" {
|
||||
if mode == "on" {
|
||||
return fmt.Errorf("cloud_sync=on but owner_user_id not set in config and $IMAGEN_OWNER_USER_ID is empty")
|
||||
}
|
||||
// auto + missing UUID = silent skip.
|
||||
return nil
|
||||
}
|
||||
|
||||
pngBytes, readErr := os.ReadFile(paths.ImagePath)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read local image: %w", readErr)
|
||||
}
|
||||
|
||||
// Reuse the writer's date/slug/seed so storage_path mirrors the local
|
||||
// filename's prefix exactly — viewers can join `imagen.images` on
|
||||
// either side without timezone drift.
|
||||
date := paths.Date
|
||||
slug := paths.Slug
|
||||
if date == "" || slug == "" {
|
||||
now := time.Now()
|
||||
date = now.Format("2006-01-02")
|
||||
slug = output.Slug(in.Prompt)
|
||||
}
|
||||
ext := in.Ext
|
||||
if ext == "" {
|
||||
ext = strings.TrimPrefix(filepath.Ext(paths.ImagePath), ".")
|
||||
}
|
||||
if ext == "" {
|
||||
ext = "png"
|
||||
}
|
||||
|
||||
// Snapshot the sidecar (if it exists) so the row carries the same
|
||||
// metadata view a downstream viewer would see on disk.
|
||||
var sidecar map[string]any
|
||||
if paths.SidecarPath != "" {
|
||||
if scBytes, err := os.ReadFile(paths.SidecarPath); err == nil {
|
||||
_ = json.Unmarshal(scBytes, &sidecar)
|
||||
}
|
||||
}
|
||||
|
||||
model := metaString(res.Metadata, "model")
|
||||
steps := metaInt(res.Metadata, "steps")
|
||||
cost := metaFloatPtr(res.Metadata, "cost_usd_estimate")
|
||||
latency := metaInt(res.Metadata, "latency_ms")
|
||||
|
||||
seed := paths.Seed
|
||||
if seed == 0 {
|
||||
seed = in.Seed
|
||||
}
|
||||
syncReq := cloud.SyncRequest{
|
||||
Date: date,
|
||||
Slug: slug,
|
||||
Seed: seed,
|
||||
Ext: ext,
|
||||
PNG: pngBytes,
|
||||
MimeType: res.MimeType,
|
||||
Prompt: in.Prompt,
|
||||
Backend: in.Backend,
|
||||
Model: model,
|
||||
Steps: steps,
|
||||
Width: width,
|
||||
Height: height,
|
||||
LatencyMs: latency,
|
||||
CostUSDEstimate: cost,
|
||||
Sidecar: sidecar,
|
||||
}
|
||||
syncCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
result, err := sink.Sync(syncCtx, syncReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result != nil && result.ImageID != "" {
|
||||
fmt.Fprintf(os.Stderr, "cloud: imagen.images.id=%s storage_path=%s\n", result.ImageID, result.StoragePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func metaString(m map[string]any, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func metaInt(m map[string]any, key string) int {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func metaFloatPtr(m map[string]any, key string) *float64 {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return &n
|
||||
case float32:
|
||||
f := float64(n)
|
||||
return &f
|
||||
case int:
|
||||
f := float64(n)
|
||||
return &f
|
||||
case int64:
|
||||
f := float64(n)
|
||||
return &f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvePreviewMode applies the precedence chain config -> env -> flag.
|
||||
// Flags win, env beats config, config beats the implicit auto default.
|
||||
func resolvePreviewMode(cfg *config.Config, flagOn, flagOff bool, env string) (preview.Mode, error) {
|
||||
|
||||
@@ -48,3 +48,40 @@ func TestResolvePreviewMode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCloudSyncMode(t *testing.T) {
|
||||
type tc struct {
|
||||
name string
|
||||
cfg *config.Config
|
||||
noCloud bool
|
||||
env string
|
||||
want string
|
||||
wantError bool
|
||||
}
|
||||
cases := []tc{
|
||||
{name: "all-empty-defaults-to-auto", want: "auto"},
|
||||
{name: "config-on", cfg: &config.Config{Output: config.OutputConfig{CloudSync: "on"}}, want: "on"},
|
||||
{name: "config-off", cfg: &config.Config{Output: config.OutputConfig{CloudSync: "off"}}, want: "off"},
|
||||
{name: "env-overrides-config", cfg: &config.Config{Output: config.OutputConfig{CloudSync: "on"}}, env: "off", want: "off"},
|
||||
{name: "flag-overrides-env-and-config", cfg: &config.Config{Output: config.OutputConfig{CloudSync: "on"}}, env: "on", noCloud: true, want: "off"},
|
||||
{name: "flag-overrides-config-on", cfg: &config.Config{Output: config.OutputConfig{CloudSync: "on"}}, noCloud: true, want: "off"},
|
||||
{name: "bad-env-errors", env: "yes", wantError: true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := resolveCloudSyncMode(c.cfg, c.noCloud, c.env)
|
||||
if c.wantError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got mode %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("mode = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user