popiart-server
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

639 lines
18 KiB

package server
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/url"
"path"
"strconv"
"strings"
"time"
)
type store struct {
cfg Config
sessions SessionRepository
jobs JobRepository
routes RouteRepository
media MediaRepository
artifacts ArtifactRepository
skills []skill
projects []project
sessionTTL time.Duration
}
func newStore(cfg Config) (*store, error) {
skills, err := loadSkills(cfg.SkillhubDir)
if err != nil {
return nil, err
}
projects := []project{
{ID: "proj_local_dev", Name: "Local Dev"},
}
repo, err := newSQLiteRepository(cfg.SQLitePath, cfg.SessionSecret)
if err != nil {
return nil, err
}
return &store{
cfg: cfg,
sessions: repo,
jobs: repo,
routes: repo,
media: repo,
artifacts: repo,
skills: skills,
projects: projects,
sessionTTL: 30 * 24 * time.Hour,
}, nil
}
func seedSkills() []skill {
return []skill{
{
ID: "popiskill-image-text2image-basic-v1",
Name: "Basic Text2Image",
Description: "Generate a single image from a text prompt through PopiNewAPI.",
Tags: []string{"image", "text2image", "basic"},
Version: "1.0.0",
ModelType: "image",
RouteKey: "image.text2image",
EstimatedDurationS: 30,
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"prompt": map[string]any{"type": "string"},
"size": map[string]any{"type": "string"},
},
"required": []string{"prompt"},
},
OutputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"artifact_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"usage": map[string]any{"type": "object"},
},
},
},
{
ID: "popiskill-image-img2img-basic-v1",
Name: "Basic Img2Img",
Description: "Edit an existing image through PopiArt using a source artifact first, or a remote reference image URL as a fallback.",
Tags: []string{"image", "img2img", "basic"},
Version: "1.0.0",
ModelType: "image",
RouteKey: "image.img2img",
EstimatedDurationS: 45,
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"prompt": map[string]any{"type": "string"},
"source_artifact_id": map[string]any{"type": "string"},
"reference_image_url": map[string]any{"type": "string"},
"image_url": map[string]any{"type": "string"},
"size": map[string]any{"type": "string"},
"aspect_ratio": map[string]any{"type": "string"},
"resolution": map[string]any{"type": "string"},
"notes": map[string]any{"type": "string"},
},
"required": []string{"prompt"},
},
OutputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"artifact_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
},
},
},
{
ID: "popiskill-video-image2video-basic-v1",
Name: "Basic Image2Video",
Description: "Reserved image2video test skill. The runtime is not connected yet.",
Tags: []string{"video", "image2video", "basic"},
Version: "1.0.0",
ModelType: "video",
RouteKey: "video.image2video",
EstimatedDurationS: 90,
InputSchema: imageToVideoInputSchema(),
OutputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"artifact_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
},
},
},
}
}
func (s *store) createSession(upstreamKey string) (string, user, bool, error) {
current, err := s.sessions.CreateSession(upstreamKey, buildUserFromUpstreamKey(upstreamKey), s.sessionTTL)
if err != nil {
return "", user{}, false, err
}
return current.Token, current.User, true, nil
}
func (s *store) session(token string) (session, bool, error) {
return s.sessions.GetSession(token)
}
func (s *store) deleteSession(token string) error {
return s.sessions.DeleteSession(token)
}
func (s *store) rotateSession(oldToken string) (string, user, bool, error) {
current, ok, err := s.sessions.RotateSession(oldToken, s.sessionTTL)
if err != nil || !ok {
return "", user{}, ok, err
}
return current.Token, current.User, true, nil
}
func (s *store) listSkills(tag, search string, limit, offset int) ([]skill, int) {
filtered := make([]skill, 0, len(s.skills))
for _, item := range s.skills {
if tag != "" && !contains(item.Tags, tag) {
continue
}
if search != "" {
haystack := strings.ToLower(item.Name + " " + item.Description + " " + strings.Join(item.Tags, " "))
if !strings.Contains(haystack, strings.ToLower(search)) {
continue
}
}
filtered = append(filtered, item)
}
total := len(filtered)
start := clamp(offset, 0, total)
end := clamp(offset+limit, start, total)
return filtered[start:end], total
}
func (s *store) getSkill(id string) (skill, bool) {
for _, item := range s.skills {
if item.ID == id {
return item, true
}
}
return skill{}, false
}
func (s *store) createJob(skillID, routeKey, modelID, execMode string, input map[string]any, projectID, priority, idem string, current session) (*job, int, error) {
now := time.Now().UTC()
record := &job{
JobID: "job_" + randomID(),
Status: "pending",
SkillID: skillID,
RouteKey: normalizeRouteKey(routeKey),
ModelID: modelID,
ExecMode: defaultString(execMode, "sync_result"),
Input: cloneMap(input),
ProjectID: projectID,
Priority: priority,
IdempotencyKey: idem,
UserID: current.User.ID,
SessionID: current.Token,
CreatedAt: now.Format(time.RFC3339),
ArtifactIDs: []string{},
UpstreamKey: current.UpstreamKey,
}
return s.jobs.CreateJob(record)
}
func (s *store) getJob(userID, jobID string) (*job, bool, error) {
record, exists, err := s.jobs.GetJob(jobID)
if err != nil || !exists {
return nil, exists, err
}
if record.UserID != userID {
return nil, false, nil
}
return record, true, nil
}
func (s *store) listJobs(userID, status, skillID, projectID string, limit, offset int) ([]job, int, error) {
return s.jobs.ListJobs(userID, status, skillID, projectID, limit, offset)
}
func (s *store) cancelJob(userID, jobID string) (*job, bool, bool, error) {
return s.jobs.CancelJob(userID, jobID)
}
func (s *store) jobLogs(userID, jobID string) ([]logEntry, bool, error) {
record, exists, err := s.getJob(userID, jobID)
if err != nil || !exists {
return nil, exists, err
}
return buildJobLogs(record), true, nil
}
func (s *store) startJob(jobID string) (*job, bool, error) {
return s.jobs.MarkJobRunning(jobID)
}
func (s *store) linkUpstreamTask(jobID, upstreamTaskID string) error {
return s.jobs.LinkUpstreamTask(jobID, upstreamTaskID)
}
func (s *store) failJob(jobID, code, message string, details map[string]any) error {
return s.jobs.FailJob(jobID, code, message, details)
}
func (s *store) completeJobWithResults(jobID string, refs []resultRef, usage map[string]any) error {
if err := s.jobs.CompleteSyncResult(jobID, refs, cloneMap(usage)); err != nil {
return err
}
record, exists, err := s.jobs.GetJob(jobID)
if err != nil || !exists || record == nil {
return err
}
return s.syncArtifactsForJob(record)
}
func (s *store) artifactsForJob(userID, jobID string) ([]artifact, bool, error) {
record, exists, err := s.getJob(userID, jobID)
if err != nil || !exists {
return nil, exists, err
}
if err := s.syncArtifactsForJob(record); err != nil {
return nil, true, err
}
if s.artifacts != nil {
items, _, err := s.artifacts.ListArtifacts(userID, "", jobID, 1000, 0)
if err != nil {
return nil, true, err
}
if len(items) > 0 || len(record.ResultRefs) == 0 {
return items, true, nil
}
}
return buildArtifacts(record), true, nil
}
func (s *store) artifactRef(userID, artifactID string) (*artifact, resultRef, bool, error) {
if s.artifacts != nil {
item, ref, exists, err := s.artifacts.GetArtifact(userID, artifactID)
if err != nil || exists {
return item, ref, exists, err
}
}
jobID, idx, ok := parseArtifactID(artifactID)
if !ok {
return nil, resultRef{}, false, nil
}
record, exists, err := s.getJob(userID, jobID)
if err != nil || !exists {
return nil, resultRef{}, exists, err
}
if err := s.syncArtifactsForJob(record); err != nil {
return nil, resultRef{}, true, err
}
if s.artifacts != nil {
item, ref, exists, err := s.artifacts.GetArtifact(userID, artifactID)
if err != nil || exists {
return item, ref, exists, err
}
}
items := buildArtifacts(record)
if idx < 0 || idx >= len(items) || idx >= len(record.ResultRefs) {
return nil, resultRef{}, false, nil
}
item := items[idx]
return &item, record.ResultRefs[idx], true, nil
}
func (s *store) listArtifacts(userID, projectID, jobID string, limit, offset int) ([]artifact, int, error) {
if limit <= 0 {
limit = 20
}
if offset < 0 {
offset = 0
}
if err := s.backfillArtifacts(userID, strings.TrimSpace(projectID), strings.TrimSpace(jobID)); err != nil {
return nil, 0, err
}
if s.artifacts != nil {
return s.artifacts.ListArtifacts(userID, projectID, jobID, limit, offset)
}
return nil, 0, nil
}
func (s *store) routesForProject(projectID string) (map[string]string, error) {
return s.routes.GetRoutes(projectID)
}
func (s *store) setRouteOverride(projectID, routeKey, modelID string) error {
return s.routes.SetRoute(projectID, normalizeRouteKey(routeKey), modelID)
}
func (s *store) unsetRouteOverride(projectID, routeKey string) error {
return s.routes.UnsetRoute(projectID, normalizeRouteKey(routeKey))
}
func (s *store) persistMediaContent(userID, projectID, artifactID, filename, contentType, visibility string, content []byte, mediaID string) (mediaRecord, error) {
return persistMediaContent(s.cfg, s.media, userID, projectID, artifactID, filename, contentType, visibility, content, mediaID)
}
func (s *store) getMedia(mediaID string) (*mediaRecord, bool, error) {
if s.media != nil {
record, exists, err := s.media.GetMedia(mediaID)
if err != nil || exists {
return record, exists, err
}
}
record, exists, err := loadMediaRecordFromJSON(s.cfg, mediaID)
if err != nil || !exists || record == nil {
return record, exists, err
}
if s.media != nil {
if upsertErr := s.media.UpsertMedia(*record); upsertErr != nil {
return nil, false, upsertErr
}
}
return record, true, nil
}
func (s *store) syncArtifactsForJob(record *job) error {
if s.artifacts == nil || record == nil {
return nil
}
return s.artifacts.UpsertArtifacts(record.UserID, record.ProjectID, buildArtifacts(record))
}
func (s *store) backfillArtifacts(userID, projectID, jobID string) error {
if s.artifacts == nil {
return nil
}
if jobID != "" {
record, exists, err := s.getJob(userID, jobID)
if err != nil || !exists || record == nil {
return err
}
if projectID != "" && record.ProjectID != projectID {
return nil
}
return s.syncArtifactsForJob(record)
}
const jobPageSize = 200
totalJobs := 0
for jobOffset := 0; ; jobOffset += jobPageSize {
jobs, total, err := s.jobs.ListJobs(userID, "", "", projectID, jobPageSize, jobOffset)
if err != nil {
return err
}
totalJobs = total
for idx := range jobs {
if err := s.syncArtifactsForJob(&jobs[idx]); err != nil {
return err
}
}
if jobOffset+len(jobs) >= totalJobs || len(jobs) == 0 {
break
}
}
return nil
}
func cloneJob(record *job) *job {
if record == nil {
return nil
}
data := *record
data.Input = cloneMap(record.Input)
if record.ResultRefs != nil {
data.ResultRefs = append([]resultRef{}, record.ResultRefs...)
}
if record.ArtifactIDs != nil {
data.ArtifactIDs = append([]string{}, record.ArtifactIDs...)
}
return &data
}
func buildUserFromUpstreamKey(key string) user {
sum := sha256.Sum256([]byte(key))
fingerprint := hex.EncodeToString(sum[:6])
short := fingerprint
if len(short) > 12 {
short = short[:12]
}
return user{
ID: "user_" + short,
Email: "",
Name: "PopiNewAPI Session",
Scopes: []string{"skills:read", "jobs:write", "artifacts:read", "artifacts:write", "budget:read"},
}
}
func buildJobLogs(record *job) []logEntry {
if record == nil {
return nil
}
logs := []logEntry{
{TS: record.CreatedAt, Level: "info", Message: "job accepted"},
}
if record.StartedAt != "" {
message := "job dispatched to PopiNewAPI"
if record.ExecMode == "upstream_task" && record.NewAPITaskID != "" {
message = fmt.Sprintf("job linked to PopiNewAPI task %s", record.NewAPITaskID)
}
logs = append(logs, logEntry{TS: record.StartedAt, Level: "info", Message: message})
}
switch record.Status {
case "done":
logs = append(logs, logEntry{TS: defaultString(record.FinishedAt, record.CreatedAt), Level: "info", Message: "job completed"})
case "failed":
message := "job failed"
if record.Error != nil && strings.TrimSpace(record.Error.Message) != "" {
message = record.Error.Message
}
logs = append(logs, logEntry{TS: defaultString(record.FinishedAt, record.CreatedAt), Level: "error", Message: message})
case "cancelled":
logs = append(logs, logEntry{TS: defaultString(record.FinishedAt, record.CreatedAt), Level: "warn", Message: "job cancelled"})
}
return logs
}
func buildArtifacts(record *job) []artifact {
if record == nil || len(record.ResultRefs) == 0 {
return nil
}
createdAt := defaultString(record.FinishedAt, record.CreatedAt)
expiresAt := ""
if created := parseRFC3339(createdAt); !created.IsZero() {
expiresAt = created.Add(30 * 24 * time.Hour).Format(time.RFC3339)
}
items := make([]artifact, 0, len(record.ResultRefs))
for idx, ref := range record.ResultRefs {
filename := strings.TrimSpace(ref.Filename)
if filename == "" {
filename = inferFilenameFromRef(ref, buildArtifactID(record.JobID, idx))
}
contentType := defaultString(strings.TrimSpace(ref.ContentType), "application/octet-stream")
storageStatus := strings.TrimSpace(ref.StorageStatus)
if storageStatus == "" {
switch {
case ref.LocalPath != "":
storageStatus = "ready"
case ref.URL != "":
storageStatus = "upstream"
case ref.DataURL != "":
storageStatus = "embedded"
}
}
item := artifact{
ID: buildArtifactID(record.JobID, idx),
JobID: record.JobID,
ProjectID: record.ProjectID,
MediaID: strings.TrimSpace(ref.MediaID),
Filename: filename,
ContentType: contentType,
SizeBytes: ref.SizeBytes,
CreatedAt: createdAt,
ExpiresAt: expiresAt,
URL: strings.TrimSpace(ref.URL),
Visibility: strings.TrimSpace(ref.Visibility),
SHA256: strings.TrimSpace(ref.SHA256),
StorageStatus: storageStatus,
SourceSkillID: strings.TrimSpace(record.SkillID),
SourceModelID: strings.TrimSpace(record.ModelID),
SourceRouteKey: strings.TrimSpace(record.RouteKey),
SourceInput: cloneMap(record.Input),
PromptText: primaryPromptText(record.Input),
Usage: cloneMap(record.Usage),
Ref: ref,
}
items = append(items, item)
}
return items
}
func buildArtifactIDs(jobID string, refs []resultRef) []string {
if len(refs) == 0 {
return nil
}
ids := make([]string, 0, len(refs))
for idx := range refs {
ids = append(ids, buildArtifactID(jobID, idx))
}
return ids
}
func primaryPromptText(input map[string]any) string {
if input == nil {
return ""
}
return strings.TrimSpace(stringValue(
input["prompt"],
input["motion_prompt"],
input["scene_prompt"],
input["text"],
))
}
func buildArtifactID(jobID string, idx int) string {
return "art_" + strings.TrimPrefix(jobID, "job_") + "_" + strconv.Itoa(idx)
}
func parseArtifactID(artifactID string) (string, int, bool) {
if !strings.HasPrefix(artifactID, "art_") {
return "", 0, false
}
value := strings.TrimPrefix(artifactID, "art_")
pos := strings.LastIndex(value, "_")
if pos <= 0 {
return "", 0, false
}
idx, err := strconv.Atoi(value[pos+1:])
if err != nil {
return "", 0, false
}
return "job_" + value[:pos], idx, true
}
func inferFilenameFromRef(ref resultRef, fallback string) string {
if ref.URL != "" {
if parsed, err := url.Parse(ref.URL); err == nil {
base := path.Base(parsed.Path)
if base != "" && base != "." && base != "/" {
return base
}
}
}
return fallback
}
func normalizeRouteKey(value string) string {
switch strings.TrimSpace(value) {
case "", "image":
return "image.text2image"
case "video":
return "video.image2video"
case "audio":
return "audio.default"
default:
return value
}
}
func routeExecMode(routeKey string) string {
switch normalizeRouteKey(routeKey) {
case "video.image2video":
return "upstream_task"
default:
return "sync_result"
}
}
func maskSecret(key string) string {
if key == "" {
return ""
}
if len(key) <= 12 {
return key
}
return key[:4] + "********" + key[len(key)-4:]
}
func contains(items []string, target string) bool {
for _, item := range items {
if strings.EqualFold(item, target) {
return true
}
}
return false
}
func clamp(v, min, max int) int {
if v < min {
return min
}
if v > max {
return max
}
return v
}
func cloneMap(src map[string]any) map[string]any {
if src == nil {
return nil
}
dst := make(map[string]any, len(src))
for key, value := range src {
dst[key] = value
}
return dst
}
func randomID() string {
buf := make([]byte, 6)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(buf)
}