diff --git a/main.go b/main.go index 0a4b69c5..c979ecbb 100644 --- a/main.go +++ b/main.go @@ -102,6 +102,14 @@ func main() { } return a.Slug, a.Name, nil } + work.ConsumeCredits = func(userID string, amount int) error { + w := wallet.GetWallet(userID) + if w.Balance < amount { + return fmt.Errorf("insufficient credits (%d available, %d needed)", w.Balance, amount) + } + wallet.ConsumeQuota(userID, wallet.OpChatQuery) + return nil + } work.Notify = func(toUserID, subject, body string) { acc, err := auth.GetAccount(toUserID) if err != nil { diff --git a/work/handlers.go b/work/handlers.go index 9476bb5c..a9b2b470 100644 --- a/work/handlers.go +++ b/work/handlers.go @@ -135,7 +135,11 @@ func handleList(w http.ResponseWriter, r *http.Request) { meta := fmt.Sprintf(`%s · %s · %s`, kindLabel, post.Author, post.Author, post.CreatedAt.Format("2 Jan 2006")) if post.Kind == KindTask && post.Cost > 0 { - meta += fmt.Sprintf(` · %d credits`, post.Cost) + if post.Spent > 0 { + meta += fmt.Sprintf(` · %d/%d credits`, post.Spent, post.Cost) + } else { + meta += fmt.Sprintf(` · %d credits`, post.Cost) + } } if len(post.Feedback) > 0 { meta += fmt.Sprintf(` · %d feedback`, len(post.Feedback)) @@ -195,7 +199,9 @@ func handleDetail(w http.ResponseWriter, r *http.Request) { sb.WriteString(fmt.Sprintf(`

%s

`, detailMeta)) if post.Kind == KindTask { - sb.WriteString(fmt.Sprintf(`

Cost: %d credits (%s)

`, post.Cost, wallet.FormatCredits(post.Cost))) + if post.Cost > 0 { + sb.WriteString(fmt.Sprintf(`

Budget: %d credits · Spent: %d credits

`, post.Cost, post.Spent)) + } if post.Status != "" { sb.WriteString(fmt.Sprintf(`

Status: %s

`, post.Status)) } @@ -229,6 +235,28 @@ func handleDetail(w http.ResponseWriter, r *http.Request) { sb.WriteString(``) } + // Agent log + if len(post.Log) > 0 { + sb.WriteString(`
`) + sb.WriteString(`

Agent Log

`) + for _, entry := range post.Log { + color := "#555" + switch entry.Step { + case "error", "budget": + color = "#c00" + case "complete": + color = "#28a745" + } + credits := "" + if entry.Credits > 0 { + credits = fmt.Sprintf(` · %d credits`, entry.Credits) + } + sb.WriteString(fmt.Sprintf(`

%s %s%s %s

`, + color, entry.Step, entry.Message, credits, entry.CreatedAt.Format("15:04:05"))) + } + sb.WriteString(`
`) + } + // Actions if sess != nil { actions := false @@ -383,7 +411,7 @@ func renderPostForm(kind, errMsg string) string { costDisplay = "block" } sb.WriteString(fmt.Sprintf(`
`, costDisplay)) - sb.WriteString(``) + sb.WriteString(``) sb.WriteString(`
`) sb.WriteString(fmt.Sprintf(`
`, costDisplay)) @@ -481,41 +509,24 @@ func handlePost(w http.ResponseWriter, r *http.Request) { kind = KindShow } - // Hold cost in escrow for tasks + // Validate budget if kind == KindTask && cost > 0 && sess.Account != "micro" { - if err := wallet.HoldEscrow(sess.Account, cost, "pending"); err != nil { - respondError(w, r, "/work?kind=task", "Insufficient credits for task cost") + wal := wallet.GetWallet(sess.Account) + if wal.Balance < cost { + respondError(w, r, "/work?kind=task", fmt.Sprintf("Insufficient credits (%d available, %d budget)", wal.Balance, cost)) return } } post, err := CreatePost(sess.Account, acc.Name, kind, title, description, link, "", cost) if err != nil { - if kind == KindTask && cost > 0 && sess.Account != "micro" { - wallet.RefundEscrow(sess.Account, cost, "failed") - } respondError(w, r, "/work?kind="+kind, err.Error()) return } // Assign to agent if requested if assign && kind == KindTask { - canProceed, _, agentCost, qerr := wallet.CheckQuota(sess.Account, wallet.OpChatQuery) - if canProceed { - wallet.ConsumeQuota(sess.Account, wallet.OpChatQuery) - AssignToAgent(post.ID, sess.Account) - } else { - msg := fmt.Sprintf("Task posted but agent assignment failed: insufficient credits (%d required)", agentCost) - if qerr != nil { - msg = "Task posted but agent assignment failed: " + qerr.Error() - } - if app.SendsJSON(r) || app.WantsJSON(r) { - app.RespondJSON(w, map[string]interface{}{"post": post, "warning": msg}) - return - } - http.Redirect(w, r, "/work/"+post.ID+"?error="+strings.ReplaceAll(msg, " ", "+"), http.StatusSeeOther) - return - } + AssignToAgent(post.ID, sess.Account) } if app.SendsJSON(r) || app.WantsJSON(r) { @@ -709,15 +720,7 @@ func handleAccept(w http.ResponseWriter, r *http.Request) { return } - // Pay out: if agent did the work, refund cost to poster (they only paid compute). - // If a human did it, release escrow to the worker. - if post.AuthorID != "micro" { - if post.WorkerID == "agent" { - wallet.RefundEscrow(post.AuthorID, post.Cost, post.ID) - } else { - wallet.ReleaseEscrow(post.WorkerID, post.Cost, post.ID) - } - } + // Credits already consumed during agent work — nothing to release // Notify the worker (if human) if post.WorkerID != "agent" && post.WorkerID != "" { @@ -766,10 +769,7 @@ func handleCancel(w http.ResponseWriter, r *http.Request) { return } - // Refund escrow to poster - if post.AuthorID != "micro" { - wallet.RefundEscrow(post.AuthorID, post.Cost, post.ID) - } + // Credits already consumed — no refund if app.SendsJSON(r) || app.WantsJSON(r) { app.RespondJSON(w, map[string]string{"status": "cancelled"}) @@ -788,18 +788,6 @@ func handleAssign(w http.ResponseWriter, r *http.Request) { id := extractPostID(r.URL.Path, "/assign") - // Consume credits for the agent build (same cost as apps_build = chat_query = 3 credits) - canProceed, _, cost, qerr := wallet.CheckQuota(sess.Account, wallet.OpChatQuery) - if !canProceed { - msg := fmt.Sprintf("Insufficient credits (%d required)", cost) - if qerr != nil { - msg = qerr.Error() - } - respondPostError(w, r, id, msg) - return - } - wallet.ConsumeQuota(sess.Account, wallet.OpChatQuery) - if err := AssignToAgent(id, sess.Account); err != nil { respondPostError(w, r, id, err.Error()) return @@ -849,13 +837,6 @@ func handleDelete(w http.ResponseWriter, r *http.Request) { return } - // Refund escrowed cost if task is open/claimed - if post.Kind == KindTask && post.Cost > 0 && post.AuthorID != "micro" { - if post.Status == StatusOpen || post.Status == StatusClaimed { - wallet.RefundEscrow(post.AuthorID, post.Cost, post.ID) - } - } - if err := DeletePost(id); err != nil { respondPostError(w, r, id, err.Error()) return diff --git a/work/work.go b/work/work.go index 3b637c77..8e519967 100644 --- a/work/work.go +++ b/work/work.go @@ -34,7 +34,8 @@ type Post struct { Title string `json:"title"` Description string `json:"description"` Link string `json:"link"` // URL, app slug, or any external link - Cost int `json:"cost"` // Credits cost for the task + Cost int `json:"cost"` // Max spend budget (credits) + Spent int `json:"spent"` // Credits consumed so far AuthorID string `json:"author_id"` Author string `json:"author"` // Display name WorkerID string `json:"worker_id"` // Who claimed a task @@ -43,6 +44,7 @@ type Post struct { Delivery string `json:"delivery"` // Deliverable for tasks Tags string `json:"tags"` // Comma-separated Tips int `json:"tips"` // Total tips received (show) + Log []LogEntry `json:"log"` // Agent work log Feedback []Comment `json:"feedback"` // Comments/feedback CreatedAt time.Time `json:"created_at"` ClaimedAt time.Time `json:"claimed_at,omitempty"` @@ -50,6 +52,14 @@ type Post struct { CompletedAt time.Time `json:"completed_at,omitempty"` } +// LogEntry records a step in the agent's work +type LogEntry struct { + Step string `json:"step"` // "build", "verify", "fix", "complete", "error", "budget" + Message string `json:"message"` // What happened + Credits int `json:"credits"` // Credits consumed in this step + CreatedAt time.Time `json:"created_at"` +} + // Comment is a piece of feedback on a work post type Comment struct { ID string `json:"id"` @@ -63,6 +73,18 @@ type Comment struct { // Takes (prompt, authorID, authorName) and returns (appSlug, appName, error). var BuildApp func(prompt, authorID, authorName string) (string, string, error) +// VerifyApp is wired by main.go to check if an app works. +// Takes (appSlug) and returns (issues string, ok bool). +var VerifyApp func(appSlug string) (string, bool) + +// FixApp is wired by main.go to fix issues with an app. +// Takes (appSlug, issues) and returns (error). +var FixApp func(appSlug, issues string) error + +// ConsumeCredits is wired by main.go to charge credits. +// Takes (userID, amount) and returns (error). +var ConsumeCredits func(userID string, amount int) error + // Notify is wired by main.go to send notifications (e.g. internal mail). // Takes (toUserID, subject, body). var Notify func(toUserID, subject, body string) @@ -83,6 +105,13 @@ func Load() { seedPosts() } data.RegisterDeleter("work", DeletePost) + + // Resume any in-progress agent tasks after startup + // (delayed to allow callbacks to be wired first) + go func() { + time.Sleep(2 * time.Second) + ResumeAgentWork() + }() } func save() { @@ -301,6 +330,39 @@ func ReleaseTask(postID, authorID string) error { return nil } +// addLog appends a log entry to a post and persists it. +func addLog(post *Post, step, message string, credits int) { + mutex.Lock() + post.Log = append(post.Log, LogEntry{ + Step: step, + Message: message, + Credits: credits, + CreatedAt: time.Now(), + }) + post.Spent += credits + save() + mutex.Unlock() +} + +// spendCredits charges credits against a task's budget. +// Returns false if the budget would be exceeded. +func spendCredits(post *Post, authorID string, amount int) bool { + if post.Cost > 0 && post.Spent+amount > post.Cost { + return false + } + if ConsumeCredits != nil { + if err := ConsumeCredits(authorID, amount); err != nil { + return false + } + } + return true +} + +const ( + maxAgentIterations = 5 + creditPerStep = 3 // credits per AI call +) + // AssignToAgent assigns an open task to the AI agent. // It claims the task as "agent", runs the app builder in a goroutine, // and posts the delivery when complete. The poster reviews the result. @@ -331,52 +393,132 @@ func AssignToAgent(postID, authorID string) error { save() mutex.Unlock() - // Run the builder in background - go func() { - if BuildApp == nil { - // No builder wired — release back to open - mutex.Lock() - post.WorkerID = "" - post.Worker = "" - post.Status = StatusOpen - post.ClaimedAt = time.Time{} - save() - mutex.Unlock() - return + // Run the agent in background + go runAgent(post) + + return nil +} + +// runAgent executes the iterative build loop for a task. +// Build → verify → fix → verify → ... until done or budget exceeded. +// All progress is logged and persisted so it survives restarts. +func runAgent(post *Post) { + if BuildApp == nil { + failAgent(post, "No builder configured") + return + } + + authorID := post.AuthorID + description := post.Description + + // Step 1: Build + if !spendCredits(post, authorID, creditPerStep) { + addLog(post, "budget", "Budget exceeded before build", 0) + failAgent(post, "Budget exceeded") + return + } + + addLog(post, "build", "Building app from description...", creditPerStep) + + slug, name, err := BuildApp(description, "agent", "agent") + if err != nil { + addLog(post, "error", fmt.Sprintf("Build failed: %v", err), 0) + failAgent(post, "Build failed: "+err.Error()) + return + } + + addLog(post, "build", fmt.Sprintf("Built %s (/apps/%s/run)", name, slug), 0) + + // Steps 2-N: Verify and fix loop + for i := 0; i < maxAgentIterations; i++ { + // Verify + if VerifyApp == nil { + // No verifier — accept what we have + break } - slug, name, err := BuildApp(post.Description, "agent", "agent") - if err != nil { - // Failed — release back to open - mutex.Lock() - post.WorkerID = "" - post.Worker = "" - post.Status = StatusOpen - post.ClaimedAt = time.Time{} - save() - mutex.Unlock() - return + if !spendCredits(post, authorID, creditPerStep) { + addLog(post, "budget", "Budget exceeded during verification", 0) + break } - // Deliver the result - delivery := fmt.Sprintf("%s — /apps/%s/run", name, slug) - mutex.Lock() - post.Delivery = delivery - post.Status = StatusDelivered - post.DeliveredAt = time.Now() - save() - authorID := post.AuthorID - title := post.Title - postID := post.ID - mutex.Unlock() + issues, ok := VerifyApp(slug) + addLog(post, "verify", fmt.Sprintf("Verify attempt %d: %s", i+1, issues), creditPerStep) - if Notify != nil { - Notify(authorID, "Agent completed: "+title, - fmt.Sprintf("The agent built %s for your task. Review it at /work/%s", name, postID)) + if ok { + addLog(post, "verify", "App verified successfully", 0) + break } - }() - return nil + // Fix + if FixApp == nil { + break + } + + if !spendCredits(post, authorID, creditPerStep) { + addLog(post, "budget", "Budget exceeded during fix", 0) + break + } + + if err := FixApp(slug, issues); err != nil { + addLog(post, "error", fmt.Sprintf("Fix failed: %v", err), creditPerStep) + break + } + + addLog(post, "fix", fmt.Sprintf("Applied fix for: %s", issues), creditPerStep) + } + + // Deliver the result + delivery := fmt.Sprintf("%s — /apps/%s/run", name, slug) + mutex.Lock() + post.Delivery = delivery + post.Status = StatusDelivered + post.DeliveredAt = time.Now() + save() + title := post.Title + postID := post.ID + mutex.Unlock() + + addLog(post, "complete", fmt.Sprintf("Delivered: %s (spent %d credits)", delivery, post.Spent), 0) + + if Notify != nil { + Notify(authorID, "Agent completed: "+title, + fmt.Sprintf("The agent built %s for your task. Spent %d of %d credits. Review it at /work/%s", name, post.Spent, post.Cost, postID)) + } +} + +// failAgent marks a task as failed and releases it back to open. +func failAgent(post *Post, reason string) { + mutex.Lock() + post.WorkerID = "" + post.Worker = "" + post.Status = StatusOpen + post.ClaimedAt = time.Time{} + save() + authorID := post.AuthorID + title := post.Title + mutex.Unlock() + + if Notify != nil { + Notify(authorID, "Agent failed: "+title, reason) + } +} + +// ResumeAgentWork restarts any in-progress agent tasks (e.g. after server restart). +func ResumeAgentWork() { + mutex.RLock() + var inProgress []*Post + for _, p := range posts { + if p.WorkerID == "agent" && p.Status == StatusClaimed { + inProgress = append(inProgress, p) + } + } + mutex.RUnlock() + + for _, p := range inProgress { + fmt.Printf("[work] Resuming agent task: %s\n", p.Title) + go runAgent(p) + } } // DeletePost removes a work post by ID