From c8b4a5d25385d2cfff741411d8afccee98597c88 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 07:28:09 +0000 Subject: [PATCH] Remove work package and all related content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleted: - work/ directory (work.go, handlers.go, seed.go) - agent/worker.go (work task executor) - internal/app/html/work.svg (nav icon) Removed from: - main.go: import, work.Load(), SpendCredits/Notify callbacks, HTTP routes (/work, /work/), chargedWriteOp entry, agent.StartWorker() call, public routes map entry - internal/api/mcp.go: work_list and work_post tool definitions - internal/app/app.go: /work nav sidebar link - internal/app/content.go: "work" from typeLabels and contentURL - internal/app/controls.go: /work/ delete redirect case - internal/event/event.go: EventTaskCreated, EventTaskRetry - admin/console.go: tasks/task commands, work import, stats count Kept: wallet escrow operations (OpEscrowHold/Release/Refund) — they are generic financial primitives not specific to work. https://claude.ai/code/session_01GRGLA9yj7BpqKiyi6xFwnm --- admin/console.go | 38 +- agent/worker.go | 444 -------------------- internal/api/mcp.go | 22 - internal/app/app.go | 1 - internal/app/content.go | 4 +- internal/app/html/work.svg | 4 - internal/event/event.go | 2 - main.go | 23 -- work/handlers.go | 824 ------------------------------------- work/seed.go | 60 --- work/work.go | 510 ----------------------- 11 files changed, 4 insertions(+), 1928 deletions(-) delete mode 100644 agent/worker.go delete mode 100644 internal/app/html/work.svg delete mode 100644 work/handlers.go delete mode 100644 work/seed.go delete mode 100644 work/work.go diff --git a/admin/console.go b/admin/console.go index 1a50ebeb..41b1210b 100644 --- a/admin/console.go +++ b/admin/console.go @@ -17,7 +17,6 @@ import ( "mu/internal/flag" "mu/user" "mu/wallet" - "mu/work" ) // InviteHandler serves the admin invite page at /admin/invite. @@ -185,7 +184,7 @@ func ConsoleHandler(w http.ResponseWriter, r *http.Request) { sb.WriteString(``) sb.WriteString(``) - sb.WriteString(`
help · users · apps · tasks · search · stats
`) + sb.WriteString(`
help · users · apps · search · stats
`) sb.WriteString(``) // JS: intercept form, use fetch, append output inline @@ -467,35 +466,6 @@ func runCommand(cmd string) string { return fmt.Sprintf("Slug: %s\nName: %s\nAuthor: %s (%s)\nPublic: %v\nInstalls: %d\nCreated: %s\nHTML: %d bytes", a.Slug, a.Name, a.Author, a.AuthorID, a.Public, a.Installs, a.CreatedAt.Format("2 Jan 2006"), len(a.HTML)) - // --- Work --- - case "tasks": - posts := work.ListPosts("task", "", 20) - var sb strings.Builder - sb.WriteString(fmt.Sprintf("%d tasks\n", len(posts))) - for _, p := range posts { - sb.WriteString(fmt.Sprintf(" [%s] %s — %s (budget:%d spent:%d)\n", p.Status, p.ID[:8], p.Title, p.Cost, p.Spent)) - } - return sb.String() - - case "task": - if arg(1) == "" { - return "usage: task " - } - p := work.GetPost(arg(1)) - if p == nil { - return "Task not found" - } - var sb strings.Builder - sb.WriteString(fmt.Sprintf("ID: %s\nTitle: %s\nStatus: %s\nAuthor: %s\nBudget: %d\nSpent: %d\nApp: %s\n", - p.ID, p.Title, p.Status, p.AuthorID, p.Cost, p.Spent, p.AppSlug)) - if len(p.Log) > 0 { - sb.WriteString(fmt.Sprintf("\nLog (%d entries):\n", len(p.Log))) - for _, e := range p.Log { - sb.WriteString(fmt.Sprintf(" %s [%s] %s\n", e.CreatedAt.Format("15:04:05"), e.Step, e.Message)) - } - } - return sb.String() - // --- Content --- case "search": if arg(1) == "" { @@ -536,9 +506,8 @@ func runCommand(cmd string) string { stats := data.GetStats() accounts := auth.GetAllAccounts() allApps := apps.GetPublicApps() - tasks := work.ListPosts("task", "", 100) - return fmt.Sprintf("Users: %d\nApps: %d\nTasks: %d\nIndex: %d entries\nSQLite: %v", - len(accounts), len(allApps), len(tasks), stats.TotalEntries, stats.UsingSQLite) + return fmt.Sprintf("Users: %d\nApps: %d\nIndex: %d entries\nSQLite: %v", + len(accounts), len(allApps), stats.TotalEntries, stats.UsingSQLite) case "types": return strings.Join(data.DeleteTypes(), ", ") @@ -547,7 +516,6 @@ func runCommand(cmd string) string { return `Users: users · user · credit Wallet: wallet Apps: apps · app -Tasks: tasks · task Content: search · delete · flags System: stats · types · help` diff --git a/agent/worker.go b/agent/worker.go deleted file mode 100644 index 8116b5f7..00000000 --- a/agent/worker.go +++ /dev/null @@ -1,444 +0,0 @@ -package agent - -import ( - "encoding/json" - "fmt" - "strings" - - "mu/apps" - "mu/internal/ai" - "mu/internal/api" - "mu/internal/auth" - "mu/internal/event" - "mu/work" -) - -const ( - creditPerCall = 100 - maxFixAttempts = 3 -) - -// StartWorker subscribes to task events and runs them using the agent's tools. -func StartWorker() { - taskSub := event.Subscribe(event.EventTaskCreated) - retrySub := event.Subscribe(event.EventTaskRetry) - - go func() { - for evt := range taskSub.Chan { - postID, _ := evt.Data["post_id"].(string) - if postID != "" { - go runTask(postID, "") - } - } - }() - - go func() { - for evt := range retrySub.Chan { - postID, _ := evt.Data["post_id"].(string) - feedback, _ := evt.Data["feedback"].(string) - if postID != "" { - go runTask(postID, feedback) - } - } - }() -} - -// runTask executes a work task. For app tasks it builds, verifies, and fixes. -// For other tasks it uses the tool planning approach. -func runTask(postID, feedback string) { - post := work.GetPost(postID) - if post == nil { - return - } - - desc := post.Description - - // Determine if this is an app-building task - isAppTask := looksLikeAppTask(desc) - - if isAppTask { - runAppTask(post, postID, feedback) - } else { - runGeneralTask(post, postID, feedback) - } -} - -// looksLikeAppTask checks if the task description is asking to build an app. -func looksLikeAppTask(desc string) bool { - lower := strings.ToLower(desc) - for _, keyword := range []string{"build an app", "build a app", "create an app", "create a app", - "build app", "make an app", "make a app", "weather app", "timer app", "calculator", - "converter", "generator", "tracker", "editor", "viewer", "tester"} { - if strings.Contains(lower, keyword) { - return true - } - } - return false -} - -// runAppTask builds an app, verifies it, and iterates until it works. -func runAppTask(post *work.Post, postID, feedback string) { - // Step 1: Build or edit the app - if feedback != "" && post.AppSlug != "" { - // Retry: edit existing app based on feedback - editApp(post, postID, feedback) - } else { - // First build - buildApp(post, postID) - } - - post = work.GetPost(postID) // refresh - if post == nil || post.AppSlug == "" { - return // build failed - } - - // Step 2: Verify and fix loop - for i := 0; i < maxFixAttempts; i++ { - if !spendCredit(post, postID) { - break - } - - work.AddLog(postID, "verify", fmt.Sprintf("Reviewing app (attempt %d)...", i+1), creditPerCall) - - issues := verifyApp(post, postID) - if issues == "" { - work.AddLog(postID, "verify", "App verified", 0) - break - } - - work.AddLog(postID, "verify", "Issues: "+issues, 0) - - // Fix - if !spendCredit(post, postID) { - break - } - work.AddLog(postID, "fix", "Fixing: "+issues, creditPerCall) - fixApp(post, postID, issues) - } - - // Deliver - post = work.GetPost(postID) - if post == nil { - return - } - work.SetDelivery(postID, fmt.Sprintf("Built app: [%s](/apps/%s) — [Launch →](/apps/%s/run)", post.AppSlug, post.AppSlug, post.AppSlug), post.AppSlug) - work.SetStatus(postID, "delivered") - work.AddLog(postID, "complete", "App delivered", 0) - notifyComplete(post, postID) -} - -// buildApp calls apps_build to create a new app. -func buildApp(post *work.Post, postID string) { - if !spendCredit(post, postID) { - failTask(postID) - return - } - - work.AddLog(postID, "build", "Building app...", creditPerCall) - - text, isErr, execErr := api.ExecuteToolAs(post.AuthorID, "apps_build", map[string]any{ - "prompt": post.Description, - }) - if execErr != nil || isErr { - errMsg := errText(text, execErr) - work.AddLog(postID, "error", "Build failed: "+errMsg, 0) - failTask(postID) - return - } - - var result struct { - Slug string `json:"slug"` - Name string `json:"name"` - } - if json.Unmarshal([]byte(text), &result) != nil || result.Slug == "" { - work.AddLog(postID, "error", "Build returned invalid result", 0) - failTask(postID) - return - } - - work.SetDelivery(postID, "", result.Slug) - work.AddLog(postID, "build", fmt.Sprintf("Built: %s → /apps/%s/run", result.Name, result.Slug), 0) -} - -// editApp edits an existing app based on user feedback. -func editApp(post *work.Post, postID, feedback string) { - if !spendCredit(post, postID) { - return - } - - work.AddLog(postID, "build", "Updating app with feedback...", creditPerCall) - - // Get current HTML - currentHTML := readAppHTML(post.AuthorID, post.AppSlug) - if currentHTML == "" { - work.AddLog(postID, "error", "Could not read current app", 0) - // Fall back to full rebuild - buildApp(post, postID) - return - } - - // Ask AI to produce updated HTML - newHTML, err := ai.Ask(&ai.Prompt{ - System: apps.BuilderSystemPrompt() + - "\n\nYou are updating an existing app. Output ONLY the complete updated HTML document. No explanation, no markdown fences.", - Question: fmt.Sprintf("Current app HTML:\n%s\n\nUser feedback — what to change:\n%s", - truncateStr(currentHTML, 8000), feedback), - Priority: ai.PriorityHigh, - Caller: "work-edit-app", - }) - if err != nil { - work.AddLog(postID, "error", "Edit failed: "+err.Error(), 0) - return - } - - newHTML = cleanHTML(newHTML) - if newHTML == "" { - work.AddLog(postID, "error", "AI returned empty HTML", 0) - return - } - - // Update in place - _, isErr, _ := api.ExecuteToolAs(post.AuthorID, "apps_edit", map[string]any{ - "slug": post.AppSlug, - "html": newHTML, - }) - if isErr { - work.AddLog(postID, "error", "Could not save updated app", 0) - return - } - - work.AddLog(postID, "build", "App updated", 0) -} - -// verifyApp tests the app by checking structure and executing API calls. -// Returns issues string (empty = passed). -func verifyApp(post *work.Post, postID string) string { - result := apps.TestApp(post.AppSlug, post.AuthorID) - if result == nil { - return "Could not test app" - } - - if len(result.Issues) > 0 { - return strings.Join(result.Issues, "; ") - } - - return "" -} - -// fixApp fixes issues in an existing app. -func fixApp(post *work.Post, postID, issues string) { - html := readAppHTML(post.AuthorID, post.AppSlug) - if html == "" { - work.AddLog(postID, "error", "Could not read app HTML for fix", 0) - return - } - - newHTML, err := ai.Ask(&ai.Prompt{ - System: apps.BuilderSystemPrompt() + - "\n\nYou are fixing an existing app. Output ONLY the complete fixed HTML document. No explanation, no markdown fences, just the HTML starting with .", - Question: fmt.Sprintf("Issues to fix:\n%s\n\nOriginal requirements:\n%s\n\nCurrent HTML:\n%s", - issues, post.Description, truncateStr(html, 6000)), - Priority: ai.PriorityHigh, - Caller: "work-fix", - }) - if err != nil { - work.AddLog(postID, "error", "Fix generation failed: "+err.Error(), 0) - return - } - - newHTML = cleanHTML(newHTML) - if newHTML == "" { - work.AddLog(postID, "error", "Fix returned empty HTML", 0) - return - } - - _, isErr, _ := api.ExecuteToolAs(post.AuthorID, "apps_edit", map[string]any{ - "slug": post.AppSlug, - "html": newHTML, - }) - if isErr { - work.AddLog(postID, "error", "Could not save fix", 0) - return - } - - work.AddLog(postID, "fix", "Fix applied", 0) -} - -// runGeneralTask handles non-app tasks using the tool planning approach. -func runGeneralTask(post *work.Post, postID, feedback string) { - prompt := post.Description - if feedback != "" { - prompt += "\n\nFeedback from previous attempt:\n" + feedback - } - - work.AddLog(postID, "plan", "Planning task...", 0) - - planResult, err := ai.Ask(&ai.Prompt{ - System: "You are an AI agent. Given a task, output ONLY a JSON array of tool calls.\n\n" + - agentToolsDesc + - "\n\nOutput format: [{\"tool\":\"tool_name\",\"args\":{}}]\nUse at most 5 tools.", - Question: prompt, - Priority: ai.PriorityHigh, - Caller: "work-agent-plan", - }) - if err != nil { - work.AddLog(postID, "error", "Planning failed: "+err.Error(), 0) - failTask(postID) - return - } - - type toolCall struct { - Tool string `json:"tool"` - Args map[string]any `json:"args"` - } - var toolCalls []toolCall - planJSON := extractJSONArray(planResult) - if err := json.Unmarshal([]byte(planJSON), &toolCalls); err != nil || len(toolCalls) == 0 { - work.AddLog(postID, "error", "No tools planned", 0) - failTask(postID) - return - } - - work.AddLog(postID, "plan", fmt.Sprintf("Planned %d tool calls", len(toolCalls)), 0) - - var results []string - for _, tc := range toolCalls { - if tc.Tool == "" { - continue - } - if !spendCredit(post, postID) { - break - } - - work.AddLog(postID, "tool", fmt.Sprintf("Running %s...", tc.Tool), creditPerCall) - - text, isErr, execErr := api.ExecuteToolAs(post.AuthorID, tc.Tool, tc.Args) - if execErr != nil || isErr { - work.AddLog(postID, "error", fmt.Sprintf("%s failed: %s", tc.Tool, errText(text, execErr)), 0) - continue - } - - if len(text) > 4000 { - text = text[:4000] + "..." - } - results = append(results, fmt.Sprintf("### %s\n%s", tc.Tool, text)) - work.AddLog(postID, "tool", fmt.Sprintf("%s — done", tc.Tool), 0) - } - - if len(results) == 0 { - work.AddLog(postID, "error", "No tools succeeded", 0) - failTask(postID) - return - } - - // Synthesise - work.AddLog(postID, "synth", "Composing result...", 0) - answer, err := ai.Ask(&ai.Prompt{ - System: "Summarise the results. Use markdown.", - Rag: results, - Question: "Task: " + prompt, - Priority: ai.PriorityHigh, - Caller: "work-agent-synth", - }) - if err != nil { - answer = "Task completed." - } - - work.SetDelivery(postID, answer, "") - work.SetStatus(postID, "delivered") - work.AddLog(postID, "complete", "Task delivered", 0) - notifyComplete(post, postID) -} - -// --- helpers --- - -func readAppHTML(authorID, slug string) string { - a := getAppBySlug(authorID, slug) - if a == nil { - return "" - } - return a.HTML -} - -func getAppBySlug(authorID, slug string) *struct { - HTML string `json:"html"` - Name string `json:"name"` - Slug string `json:"slug"` -} { - text, isErr, err := api.ExecuteToolAs(authorID, "apps_read", map[string]any{"slug": slug}) - if err != nil || isErr { - return nil - } - var result struct { - HTML string `json:"html"` - Name string `json:"name"` - Slug string `json:"slug"` - } - if json.Unmarshal([]byte(text), &result) != nil { - return nil - } - return &result -} - -// cleanHTML strips markdown fences and leading/trailing whitespace from AI HTML output. -func cleanHTML(s string) string { - s = strings.TrimSpace(s) - // Strip markdown code fences - if strings.HasPrefix(s, "```html") { - s = strings.TrimPrefix(s, "```html") - } else if strings.HasPrefix(s, "```") { - s = strings.TrimPrefix(s, "```") - } - if strings.HasSuffix(s, "```") { - s = strings.TrimSuffix(s, "```") - } - s = strings.TrimSpace(s) - // Must start with DOCTYPE or 200 { - return text[:200] - } - return text -} - -func spendCredit(post *work.Post, postID string) bool { - // Admin bypasses budget and credit checks - if acc, err := auth.GetAccount(post.AuthorID); err == nil && acc.Admin { - return true - } - if post.Cost > 0 && work.BudgetRemaining(postID) < creditPerCall { - work.AddLog(postID, "budget", "Budget exceeded", 0) - return false - } - if work.SpendCredits != nil { - if err := work.SpendCredits(post.AuthorID, creditPerCall, "work_agent"); err != nil { - work.AddLog(postID, "budget", "Insufficient credits", 0) - return false - } - } - return true -} - -func truncateStr(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "..." -} - -func notifyComplete(post *work.Post, postID string) { - // No email — user sees live progress on the task page -} - -func failTask(postID string) { - work.SetStatus(postID, "open") -} diff --git a/internal/api/mcp.go b/internal/api/mcp.go index da33ad66..f50e5fc0 100644 --- a/internal/api/mcp.go +++ b/internal/api/mcp.go @@ -351,28 +351,6 @@ var tools = []Tool{ Method: "GET", Path: "/wallet/topup", }, - { - Name: "work_list", - Description: "List work posts — show (people sharing work) and tasks (bounties for work needed)", - Method: "GET", - Path: "/work", - Params: []ToolParam{ - {Name: "kind", Type: "string", Description: "Filter by kind: task or show (default: all)", Required: false}, - }, - }, - { - Name: "work_post", - Description: "Post work — show something you built or post a task with a credit cost", - Method: "POST", - Path: "/work/post", - Params: []ToolParam{ - {Name: "kind", Type: "string", Description: "Post kind: show or task (default: show)", Required: false}, - {Name: "title", Type: "string", Description: "Title", Required: true}, - {Name: "description", Type: "string", Description: "Description of the work", Required: true}, - {Name: "link", Type: "string", Description: "URL or app slug (optional, for show posts)", Required: false}, - {Name: "cost", Type: "number", Description: "Budget in credits — max spend for agent (required for tasks)", Required: false}, - }, - }, // Stream (console) { Name: "stream", diff --git a/internal/app/app.go b/internal/app/app.go index 836ec2a5..72b070b3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -323,7 +323,6 @@ var Template = ` Social Video Web - Work Wallet diff --git a/internal/app/content.go b/internal/app/content.go index 26a77b58..a9c620d7 100644 --- a/internal/app/content.go +++ b/internal/app/content.go @@ -52,8 +52,6 @@ func contentURL(contentType, contentID string) string { switch contentType { case "post": return "/blog/post?id=" + contentID - case "work": - return "/work/" + contentID case "app": return "/apps/" + contentID case "social": @@ -99,7 +97,7 @@ func renderMenu(actions []Action) string { case a.Label == "Delete" && a.Confirm != "": // Use POST (not DELETE) — handlers check for POST. // Redirect to the parent listing page, derived from the URL pattern. - sb.WriteString(fmt.Sprintf(`%s`, style, a.Confirm, a.URL, a.URL, a.Label)) + sb.WriteString(fmt.Sprintf(`%s`, style, a.Confirm, a.URL, a.URL, a.Label)) case a.Confirm != "": sb.WriteString(fmt.Sprintf(`%s`, style, a.Confirm, a.URL, a.Label)) default: diff --git a/internal/app/html/work.svg b/internal/app/html/work.svg deleted file mode 100644 index 30a3d6fe..00000000 --- a/internal/app/html/work.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/internal/event/event.go b/internal/event/event.go index 769acf40..c5694fce 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -16,8 +16,6 @@ const ( EventSummaryGenerated = "summary_generated" EventGenerateTag = "generate_tag" EventTagGenerated = "tag_generated" - EventTaskCreated = "task_created" - EventTaskRetry = "task_retry" ) // Event represents a data event diff --git a/main.go b/main.go index de8ed440..e08ed0c9 100644 --- a/main.go +++ b/main.go @@ -38,7 +38,6 @@ import ( "mu/video" "mu/wallet" "mu/weather" - "mu/work" ) var EnvFlag = flag.String("env", "dev", "Set the environment") @@ -100,22 +99,8 @@ func main() { // load apps apps.Load() - // load work (task bounties) - work.Load() - // Wire work credit spending - work.SpendCredits = func(userID string, amount int, operation string) error { - return wallet.DeductCredits(userID, amount, operation, nil) - } - // Wire work notifications - work.Notify = func(toUserID, subject, body, threadID string) { - acc, err := auth.GetAccount(toUserID) - if err != nil { - return - } - mail.SendMessage("Mu", "micro", acc.Name, toUserID, subject, body, threadID, "") - } @@ -639,9 +624,6 @@ func main() { return answer, nil }) - // Start the agent worker after all tools are registered - agent.StartWorker() - authenticated := map[string]bool{ "/video": false, // Public viewing, auth for interactive features "/news": false, // Public viewing, auth for search @@ -838,8 +820,6 @@ func main() { http.HandleFunc("/apps/", apps.Handler) // serve work (task bounties) - http.HandleFunc("/work", work.Handler) - http.HandleFunc("/work/", work.Handler) // content controls (flag, save, dismiss, block, share) http.HandleFunc("/app/", app.ControlsHandler) @@ -1286,9 +1266,6 @@ func chargedWriteOp(r *http.Request) string { return wallet.OpSocialPost case path == "/apps/build/generate", path == "/apps/framework/generate": return wallet.OpAppBuild - // Work - case path == "/work/post": - return wallet.OpSocialPost // Stream (console) case path == "/stream": return wallet.OpSocialPost diff --git a/work/handlers.go b/work/handlers.go deleted file mode 100644 index 974d3a80..00000000 --- a/work/handlers.go +++ /dev/null @@ -1,824 +0,0 @@ -package work - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - - "mu/internal/app" - "mu/internal/auth" - "mu/mail" - "mu/wallet" -) - -// Handler handles work-related HTTP requests -func Handler(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - - switch { - case path == "/work" && r.Method == "GET": - handleList(w, r) - case path == "/work/post" && r.Method == "GET": - handlePostForm(w, r) - case path == "/work/post" && r.Method == "POST": - handlePost(w, r) - case strings.HasPrefix(path, "/work/") && strings.HasSuffix(path, "/accept") && r.Method == "POST": - handleAccept(w, r) - case strings.HasPrefix(path, "/work/") && strings.HasSuffix(path, "/retry") && r.Method == "POST": - handleRetry(w, r) - case strings.HasPrefix(path, "/work/") && strings.HasSuffix(path, "/cancel") && r.Method == "POST": - handleCancel(w, r) - case strings.HasPrefix(path, "/work/") && strings.HasSuffix(path, "/tip") && r.Method == "POST": - handleTip(w, r) - case strings.HasPrefix(path, "/work/") && strings.HasSuffix(path, "/feedback") && r.Method == "POST": - handleFeedback(w, r) - case strings.HasPrefix(path, "/work/") && strings.HasSuffix(path, "/delete") && r.Method == "POST": - handleDelete(w, r) - case strings.HasPrefix(path, "/work/") && r.Method == "GET": - handleDetail(w, r) - default: - http.NotFound(w, r) - } -} - -func handleList(w http.ResponseWriter, r *http.Request) { - kind := r.URL.Query().Get("kind") - status := r.URL.Query().Get("status") - - sess, acc := auth.TrySession(r) - var userID string - var isAdmin bool - if sess != nil { - userID = sess.Account - isAdmin = acc.Admin - } - - // "mine" is a virtual filter — show posts by the current user - actualKind := kind - if kind == "mine" { - actualKind = "" - } - allPosts := ListPosts(actualKind, status, 50) - - if kind == "mine" && userID != "" { - var mine []*Post - for _, p := range allPosts { - if p.AuthorID == userID || p.WorkerID == userID { - mine = append(mine, p) - } - } - allPosts = mine - } - - if app.WantsJSON(r) { - app.RespondJSON(w, map[string]interface{}{"posts": allPosts}) - return - } - - var sb strings.Builder - - // Filter tabs + new post link - sb.WriteString(`
`) - sb.WriteString(`
`) - for _, f := range []struct{ val, label string }{ - {"", "All"}, - {"show", "Show"}, - {"task", "Tasks"}, - {"mine", "Mine"}, - } { - style := "padding:4px 12px;border-radius:12px;font-size:13px;text-decoration:none;color:#555" - if f.val == kind { - style = "padding:4px 12px;border-radius:12px;font-size:13px;text-decoration:none;background:#000;color:#fff" - } - href := "/work" - if f.val != "" { - href += "?kind=" + f.val - } - sb.WriteString(fmt.Sprintf(`%s`, href, style, f.label)) - } - if sess != nil { - sb.WriteString(`+ New`) - } - sb.WriteString(`
`) - sb.WriteString(`
`) - - if len(allPosts) == 0 { - if sess != nil { - sb.WriteString(`

No posts yet. Create one →

`) - } else { - sb.WriteString(`

No posts yet. Login to create one.

`) - } - } - - for _, post := range allPosts { - if userID != "" && (app.IsBlocked(userID, post.AuthorID) || app.IsDismissed(userID, "work", post.ID)) { - continue - } - sb.WriteString(`
`) - - kindLabel := "Show" - if post.Kind == KindTask { - kindLabel = "Task" - switch post.Status { - case StatusClaimed: - kindLabel += " · building" - case StatusDelivered: - kindLabel += " · delivered" - case StatusCompleted: - kindLabel += " · completed" - case StatusCancelled: - kindLabel += " · cancelled" - case StatusOpen: - kindLabel += " · open" - } - } - - sb.WriteString(fmt.Sprintf(`

%s

`, post.ID, post.Title)) - - meta := fmt.Sprintf(`%s · %s · %s`, kindLabel, post.AuthorID, post.Author, post.CreatedAt.Format("2 Jan 2006")) - if post.Kind == KindTask && post.Cost > 0 { - 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)) - } - meta += app.ItemControls(userID, isAdmin, "work", post.ID, post.AuthorID, "", "/work/"+post.ID+"/delete") - sb.WriteString(fmt.Sprintf(`

%s

`, meta)) - - sb.WriteString(`
`) - } - - html := app.RenderHTMLForRequest("Work", "Share work, get feedback, post tasks", sb.String(), r) - w.Write([]byte(html)) -} - -func handleDetail(w http.ResponseWriter, r *http.Request) { - id := strings.TrimPrefix(r.URL.Path, "/work/") - post := GetPost(id) - if post == nil { - http.NotFound(w, r) - return - } - - if app.WantsJSON(r) { - app.RespondJSON(w, post) - return - } - - sess, acc := auth.TrySession(r) - var userID string - var isAdmin bool - if sess != nil { - userID = sess.Account - isAdmin = acc.Admin - } - - var sb strings.Builder - - // Error/success messages - if errMsg := r.URL.Query().Get("error"); errMsg != "" { - sb.WriteString(fmt.Sprintf(`

%s

`, errMsg)) - } - if msg := r.URL.Query().Get("success"); msg != "" { - sb.WriteString(fmt.Sprintf(`

%s

`, msg)) - } - - // === Single task card === - sb.WriteString(`
`) - - // Meta line - kindLabel := "Show" - if post.Kind == KindTask { - kindLabel = "Task" - } - detailMeta := fmt.Sprintf(`%s · %s · %s`, - kindLabel, post.AuthorID, post.Author, post.CreatedAt.Format("2 Jan 2006 15:04")) - detailMeta += app.ItemControls(userID, isAdmin, "work", post.ID, post.AuthorID, "", "/work/"+post.ID+"/delete") - sb.WriteString(fmt.Sprintf(`

%s

`, detailMeta)) - - // Description - for _, para := range strings.Split(post.Description, "\n") { - para = strings.TrimSpace(para) - if para != "" { - sb.WriteString(fmt.Sprintf(`

%s

`, para)) - } - } - - // Task info - if post.Kind == KindTask { - statusLabel := post.Status - if post.Status == StatusClaimed { - statusLabel = "building" - } - info := fmt.Sprintf(`Status: %s`, statusLabel) - if post.Cost > 0 { - info += fmt.Sprintf(` · Budget: %d · Spent: %d`, post.Cost, post.Spent) - } - sb.WriteString(fmt.Sprintf(`

%s

`, info)) - } - if post.Link != "" { - sb.WriteString(fmt.Sprintf(`

%s

`, post.Link, post.Link)) - } - - // Cancel button (inline, not a separate card) - if sess != nil && post.Kind == KindTask && userID == post.AuthorID { - if post.Status == StatusOpen || post.Status == StatusClaimed { - sb.WriteString(fmt.Sprintf(`
`, post.ID)) - sb.WriteString(``) - sb.WriteString(`
`) - } - } - - sb.WriteString(`
`) // end task card - - // === App preview === - if post.AppSlug != "" { - appURL := "/apps/" + post.AppSlug - sb.WriteString(fmt.Sprintf(``, appURL, appURL)) - } - - // === Result (markdown delivery) === - if post.Delivery != "" { - sb.WriteString(`
`) - sb.WriteString(app.RenderString(post.Delivery)) - sb.WriteString(`
`) - } - - // === Retry / Accept (delivered tasks) === - if post.Status == StatusDelivered && userID == post.AuthorID { - sb.WriteString(`
`) - sb.WriteString(fmt.Sprintf(`
`, post.ID)) - sb.WriteString(``) - sb.WriteString(`
`) - sb.WriteString(``) - sb.WriteString(``) - sb.WriteString(fmt.Sprintf(`
`, post.ID)) - sb.WriteString(``) - sb.WriteString(`
`) - sb.WriteString(`
`) - sb.WriteString(`
`) - } - - // === Agent log (collapsible) === - if len(post.Log) > 0 || (post.Status == StatusClaimed && post.WorkerID == "agent") { - sb.WriteString(`
`) - sb.WriteString(`
`) - sb.WriteString(fmt.Sprintf(`Agent Log (%d)`, len(post.Log))) - sb.WriteString(`
`) - for _, entry := range post.Log { - color := "#555" - switch entry.Step { - case "error", "budget": - color = "#c00" - case "complete": - color = "#1a7f37" - case "info": - color = "#888" - } - credits := "" - if entry.Credits > 0 { - credits = fmt.Sprintf(` · %dc`, entry.Credits) - } - sb.WriteString(fmt.Sprintf(`

%s %s%s

`, - color, entry.Step, entry.Message, credits)) - } - sb.WriteString(`
`) - sb.WriteString(`
`) - sb.WriteString(`
`) - } - - // === Feedback === - if len(post.Feedback) > 0 || sess != nil { - sb.WriteString(`
`) - if len(post.Feedback) > 0 { - for _, fb := range post.Feedback { - sb.WriteString(fmt.Sprintf(`
%s %s

%s

`, - fb.Author, fb.Author, fb.CreatedAt.Format("2 Jan 15:04"), fb.Text)) - } - } - if sess != nil { - sb.WriteString(fmt.Sprintf(`
`, post.ID)) - sb.WriteString(``) - sb.WriteString(``) - sb.WriteString(`
`) - } - sb.WriteString(`
`) - } - - // Live polling while building - if post.Status == StatusClaimed && post.WorkerID == "agent" { - sb.WriteString(fmt.Sprintf(``, len(post.Log), post.ID)) - } - - html := app.RenderHTMLForRequest(post.Title, "Work", sb.String(), r) - w.Write([]byte(html)) -} - -// renderPostForm returns the HTML for the work post form. -// kind is the default kind ("show" or "task"), errMsg shows an error if set. -func renderPostForm(kind, errMsg string) string { - var sb strings.Builder - - if errMsg != "" { - sb.WriteString(fmt.Sprintf(`

%s

`, errMsg)) - } - - isTask := kind == "task" - - sb.WriteString(`
`) - - // Kind selector - sb.WriteString(`
`) - for _, k := range []struct{ val, label string }{ - {"show", "Show"}, - {"task", "Task"}, - } { - checked := "" - active := "" - if k.val == kind { - checked = " checked" - active = "background:#000;color:#fff;" - } - sb.WriteString(fmt.Sprintf(``, active, k.val, checked, k.label)) - } - sb.WriteString(`
`) - - titlePlaceholder := "What did you build?" - descPlaceholder := "Tell people about it..." - if isTask { - titlePlaceholder = "What needs to be done?" - descPlaceholder = "Describe the task..." - } - - sb.WriteString(``) - - sb.WriteString(`
`) - sb.WriteString(``) - sb.WriteString(`
`) - - // Show-only: link field - linkDisplay := "block" - if isTask { - linkDisplay = "none" - } - sb.WriteString(fmt.Sprintf(``) - - // Task-only: budget - costDisplay := "none" - if isTask { - costDisplay = "block" - } - sb.WriteString(fmt.Sprintf(`
`, costDisplay)) - sb.WriteString(``) - sb.WriteString(`
`) - - btnLabel := "Post" - if isTask { - btnLabel = "Start" - } - sb.WriteString(fmt.Sprintf(``, btnLabel)) - sb.WriteString(`
`) - - // JS to toggle fields and placeholders - sb.WriteString(``) - - return sb.String() -} - -func handlePostForm(w http.ResponseWriter, r *http.Request) { - _, _, err := auth.RequireSession(r) - if err != nil { - app.RedirectToLogin(w, r) - return - } - - kind := r.URL.Query().Get("kind") - if kind == "" { - kind = "show" - } - errMsg := r.URL.Query().Get("error") - - content := `
` + renderPostForm(kind, errMsg) + `
` - html := app.RenderHTMLForRequest("Share Work", "Post your work or a task", content, r) - w.Write([]byte(html)) -} - -func handlePost(w http.ResponseWriter, r *http.Request) { - sess, acc, err := auth.RequireSession(r) - if err != nil { - handleAuthError(w, r) - return - } - - var kind, title, description, link string - var cost int - - if app.SendsJSON(r) { - var body struct { - Kind string `json:"kind"` - Title string `json:"title"` - Description string `json:"description"` - Link string `json:"link"` - Cost int `json:"cost"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - app.RespondJSON(w, map[string]string{"error": "invalid request body"}) - return - } - kind = body.Kind - title = body.Title - description = body.Description - link = body.Link - cost = body.Cost - } else { - r.ParseForm() - kind = r.FormValue("kind") - title = r.FormValue("title") - description = r.FormValue("description") - link = r.FormValue("link") - fmt.Sscanf(r.FormValue("cost"), "%d", &cost) - } - - kind = strings.TrimSpace(kind) - title = strings.TrimSpace(title) - description = strings.TrimSpace(description) - link = strings.TrimSpace(link) - - if kind == "" { - kind = KindShow - } - - // Validate budget (skip for admin) - if kind == KindTask && cost > 0 && !acc.Admin { - 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 { - respondError(w, r, "/work?kind="+kind, err.Error()) - return - } - - // Auto-assign agent for tasks - if kind == KindTask { - AssignToAgent(post.ID, sess.Account) - } - - if app.SendsJSON(r) || app.WantsJSON(r) { - app.RespondJSON(w, post) - return - } - - successMsg := "" - if kind == KindTask { - successMsg = "?success=Task+started" - } - http.Redirect(w, r, "/work/"+post.ID+successMsg, http.StatusSeeOther) -} - -func handleTip(w http.ResponseWriter, r *http.Request) { - sess, _, err := auth.RequireSession(r) - if err != nil { - handleAuthError(w, r) - return - } - - id := extractPostID(r.URL.Path, "/tip") - post := GetPost(id) - if post == nil { - respondPostError(w, r, id, "Post not found") - return - } - - if sess.Account == post.AuthorID { - respondPostError(w, r, id, "Cannot tip your own work") - return - } - - var amount int - if app.SendsJSON(r) { - var body struct { - Amount int `json:"amount"` - } - json.NewDecoder(r.Body).Decode(&body) - amount = body.Amount - } else { - r.ParseForm() - fmt.Sscanf(r.FormValue("amount"), "%d", &amount) - } - - if amount < 1 { - respondPostError(w, r, id, "Tip must be at least 1 credit") - return - } - if amount > 50000 { - respondPostError(w, r, id, "Maximum tip is 50,000 credits") - return - } - - // Transfer credits from tipper to author - if err := wallet.TransferCredits(sess.Account, post.AuthorID, amount); err != nil { - respondPostError(w, r, id, err.Error()) - return - } - - // Record the tip on the post - TipPost(id, amount) - - if app.SendsJSON(r) || app.WantsJSON(r) { - app.RespondJSON(w, map[string]interface{}{"status": "tipped", "amount": amount}) - return - } - - http.Redirect(w, r, "/work/"+id+"?success=Tipped+"+fmt.Sprintf("%d", amount)+"+credits", http.StatusSeeOther) -} - -func handleFeedback(w http.ResponseWriter, r *http.Request) { - sess, acc, err := auth.RequireSession(r) - if err != nil { - handleAuthError(w, r) - return - } - - id := extractPostID(r.URL.Path, "/feedback") - - var text string - if app.SendsJSON(r) { - var body struct { - Text string `json:"text"` - } - json.NewDecoder(r.Body).Decode(&body) - text = body.Text - } else { - r.ParseForm() - text = r.FormValue("text") - } - text = strings.TrimSpace(text) - - if err := AddFeedback(id, sess.Account, acc.Name, text); err != nil { - respondPostError(w, r, id, err.Error()) - return - } - - if app.SendsJSON(r) || app.WantsJSON(r) { - app.RespondJSON(w, map[string]string{"status": "ok"}) - return - } - - http.Redirect(w, r, "/work/"+id, http.StatusSeeOther) -} - -func handleAccept(w http.ResponseWriter, r *http.Request) { - sess, _, err := auth.RequireSession(r) - if err != nil { - handleAuthError(w, r) - return - } - - id := extractPostID(r.URL.Path, "/accept") - post := GetPost(id) - if post == nil { - respondPostError(w, r, id, "Post not found") - return - } - - if err := AcceptTask(id, sess.Account); err != nil { - respondPostError(w, r, id, err.Error()) - return - } - - // Credits already consumed during agent work — nothing to release - - // Notify the worker (if human) - if post.WorkerID != "agent" && post.WorkerID != "" { - notifyWork(post.WorkerID, "Task accepted: "+post.Title, - fmt.Sprintf("Your delivery was accepted and %d credits have been released.\n\n[View task →](/work/%s)", post.Cost, id), id) - } - - if app.SendsJSON(r) || app.WantsJSON(r) { - app.RespondJSON(w, map[string]string{"status": "completed"}) - return - } - - http.Redirect(w, r, "/work/"+id+"?success=Accepted+and+paid", http.StatusSeeOther) -} - -func handleCancel(w http.ResponseWriter, r *http.Request) { - sess, _, err := auth.RequireSession(r) - if err != nil { - handleAuthError(w, r) - return - } - - id := extractPostID(r.URL.Path, "/cancel") - post := GetPost(id) - if post == nil { - respondPostError(w, r, id, "Post not found") - return - } - - // If task was claimed but not delivered, release back to open - if post.Status == StatusClaimed { - if err := ReleaseTask(id, sess.Account); err != nil { - respondPostError(w, r, id, err.Error()) - return - } - if app.SendsJSON(r) || app.WantsJSON(r) { - app.RespondJSON(w, map[string]string{"status": "released"}) - return - } - http.Redirect(w, r, "/work/"+id+"?success=Claim+released", http.StatusSeeOther) - return - } - - if err := CancelTask(id, sess.Account); err != nil { - respondPostError(w, r, id, err.Error()) - return - } - - // Credits already consumed — no refund - - if app.SendsJSON(r) || app.WantsJSON(r) { - app.RespondJSON(w, map[string]string{"status": "cancelled"}) - return - } - - http.Redirect(w, r, "/work?success=Task+cancelled", http.StatusSeeOther) -} - -func handleRetry(w http.ResponseWriter, r *http.Request) { - sess, _, err := auth.RequireSession(r) - if err != nil { - handleAuthError(w, r) - return - } - - id := extractPostID(r.URL.Path, "/retry") - post := GetPost(id) - if post == nil { - respondPostError(w, r, id, "Post not found") - return - } - if post.AuthorID != sess.Account { - respondPostError(w, r, id, "Only the poster can retry") - return - } - if post.Status != StatusDelivered { - respondPostError(w, r, id, "Task is not in delivered state") - return - } - - r.ParseForm() - feedback := strings.TrimSpace(r.FormValue("feedback")) - if feedback == "" { - respondPostError(w, r, id, "Feedback is required for retry") - return - } - - // Reset to claimed and re-run agent with the feedback - RetryWithFeedback(id, feedback) - - if app.SendsJSON(r) || app.WantsJSON(r) { - app.RespondJSON(w, map[string]string{"status": "retrying"}) - return - } - - http.Redirect(w, r, "/work/"+id+"?success=Retrying+with+feedback", http.StatusSeeOther) -} - -func extractPostID(path, suffix string) string { - path = strings.TrimPrefix(path, "/work/") - path = strings.TrimSuffix(path, suffix) - return path -} - -func handleAuthError(w http.ResponseWriter, r *http.Request) { - if app.SendsJSON(r) || app.WantsJSON(r) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"error":"authentication required"}`)) - return - } - app.RedirectToLogin(w, r) -} - -func handleDelete(w http.ResponseWriter, r *http.Request) { - sess, acc, err := auth.RequireSession(r) - if err != nil { - handleAuthError(w, r) - return - } - - id := extractPostID(r.URL.Path, "/delete") - post := GetPost(id) - if post == nil { - respondPostError(w, r, id, "Post not found") - return - } - - // Only admin or the author can delete - if !acc.Admin && sess.Account != post.AuthorID { - respondPostError(w, r, id, "You can only delete your own posts") - return - } - - if err := DeletePost(id); err != nil { - respondPostError(w, r, id, err.Error()) - return - } - - if app.SendsJSON(r) || app.WantsJSON(r) { - app.RespondJSON(w, map[string]string{"status": "deleted"}) - return - } - - http.Redirect(w, r, "/work?success=Post+deleted", http.StatusSeeOther) -} - -func respondError(w http.ResponseWriter, r *http.Request, redirect, msg string) { - if app.SendsJSON(r) || app.WantsJSON(r) { - app.RespondJSON(w, map[string]string{"error": msg}) - return - } - http.Redirect(w, r, redirect+"?error="+strings.ReplaceAll(msg, " ", "+"), http.StatusSeeOther) -} - -func respondPostError(w http.ResponseWriter, r *http.Request, id, msg string) { - respondError(w, r, "/work/"+id, msg) -} - -// notifyWork sends an internal mail notification for work events. -// postID is used for threading — all notifications for the same task are grouped. -func notifyWork(toID, subject, body, postID string) { - acc, err := auth.GetAccount(toID) - if err != nil { - return - } - mail.SendMessage("Mu", "micro", acc.Name, toID, subject, body, postID, "") -} diff --git a/work/seed.go b/work/seed.go deleted file mode 100644 index 645daaee..00000000 --- a/work/seed.go +++ /dev/null @@ -1,60 +0,0 @@ -package work - -import ( - "time" - - "github.com/google/uuid" -) - -// seedPosts creates initial work posts -func seedPosts() { - seeds := []struct { - kind string - title string - description string - link string - cost int - tags string - }{ - // Show posts — examples of what sharing looks like - { - kind: KindShow, - title: "Mu — Apps Without Ads", - description: "Built a platform that brings together the daily tools people use — news, search, chat, video, email, markets — in one place with zero ads and zero tracking. Single Go binary, self-hostable, open source. AI agents can access everything via MCP with x402 crypto payments.", - link: "https://mu.xyz", - tags: "platform,go,open-source", - }, - { - kind: KindShow, - title: "x402 — HTTP Payments Protocol", - description: "The x402 protocol adds native payment semantics to HTTP. When a resource requires payment, the server returns 402 with payment requirements. The client pays on-chain and retries. No accounts, no API keys. Designed for AI agents paying for API access.", - link: "https://x402.org", - tags: "protocol,crypto,payments", - }, - } - - now := time.Now() - - mutex.Lock() - for i, s := range seeds { - post := &Post{ - ID: uuid.New().String(), - Kind: s.kind, - Title: s.title, - Description: s.description, - Link: s.link, - Cost: s.cost, - AuthorID: "micro", - Author: "Micro", - Tags: s.tags, - Feedback: []Comment{}, - CreatedAt: now.Add(-time.Duration(i) * time.Minute), - } - if s.kind == KindTask { - post.Status = StatusOpen - } - posts[post.ID] = post - } - save() - mutex.Unlock() -} diff --git a/work/work.go b/work/work.go deleted file mode 100644 index 3cf49906..00000000 --- a/work/work.go +++ /dev/null @@ -1,510 +0,0 @@ -package work - -import ( - "encoding/json" - "errors" - "fmt" - "sync" - "time" - - "mu/internal/data" - "mu/internal/event" - - "github.com/google/uuid" -) - -// Post kinds -const ( - KindTask = "task" // Looking for someone to build something - KindShow = "show" // Sharing work you've done -) - -// Task states (only relevant for kind=task) -const ( - StatusOpen = "open" // Accepting claims - StatusClaimed = "claimed" // Someone is working on it - StatusDelivered = "delivered" // Work submitted, awaiting acceptance - StatusCompleted = "completed" // Accepted and paid - StatusCancelled = "cancelled" // Cancelled by poster -) - -// Post represents a work post — either a task (request) or show (share) -type Post struct { - ID string `json:"id"` - Kind string `json:"kind"` // "task" or "show" - Title string `json:"title"` - Description string `json:"description"` - Link string `json:"link"` // URL, app slug, or any external link - 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 - Worker string `json:"worker"` // Worker display name - Status string `json:"status"` // Task status (open/claimed/delivered/completed/cancelled) - Delivery string `json:"delivery"` // Deliverable text (markdown) - AppSlug string `json:"app_slug"` // App slug if task built an app - 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"` - DeliveredAt time.Time `json:"delivered_at,omitempty"` - 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"` - AuthorID string `json:"author_id"` - Author string `json:"author"` - Text string `json:"text"` - CreatedAt time.Time `json:"created_at"` -} - -// Notify is wired by main.go to send notifications. -var Notify func(toUserID, subject, body, threadID string) - -var ( - mutex sync.RWMutex - posts = map[string]*Post{} -) - -func init() { - b, _ := data.LoadFile("work.json") - json.Unmarshal(b, &posts) -} - -// Load initializes the work building block -func Load() { - if len(posts) == 0 { - 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() { - data.SaveJSON("work.json", posts) -} - -// CreatePost creates a new work post (task or show) -func CreatePost(authorID, author, kind, title, description, link, tags string, cost int) (*Post, error) { - if title == "" { - return nil, errors.New("title is required") - } - if description == "" { - return nil, errors.New("description is required") - } - if kind != KindTask && kind != KindShow { - return nil, errors.New("kind must be task or show") - } - if kind == KindTask { - if cost < 1 { - return nil, errors.New("cost must be at least 1 credit") - } - if cost > 50000 { - return nil, errors.New("maximum cost is 50,000 credits") - } - } - - post := &Post{ - ID: uuid.New().String(), - Kind: kind, - Title: title, - Description: description, - Link: link, - Cost: cost, - AuthorID: authorID, - Author: author, - Status: StatusOpen, - Tags: tags, - Feedback: []Comment{}, - CreatedAt: time.Now(), - } - - if kind == KindShow { - post.Status = "" // shows don't have task status - } - - mutex.Lock() - posts[post.ID] = post - save() - mutex.Unlock() - - return post, nil -} - -// AddFeedback adds a comment to a work post -func AddFeedback(postID, authorID, author, text string) error { - if text == "" { - return errors.New("feedback text is required") - } - - mutex.Lock() - defer mutex.Unlock() - - post, exists := posts[postID] - if !exists { - return errors.New("post not found") - } - - comment := Comment{ - ID: uuid.New().String(), - AuthorID: authorID, - Author: author, - Text: text, - CreatedAt: time.Now(), - } - post.Feedback = append(post.Feedback, comment) - - save() - return nil -} - -// TipPost records a tip on a show post -func TipPost(postID string, amount int) { - mutex.Lock() - defer mutex.Unlock() - - post, exists := posts[postID] - if !exists { - return - } - post.Tips += amount - save() -} - -// ClaimTask marks a task as claimed by a worker -func ClaimTask(postID, workerID, worker string) error { - mutex.Lock() - defer mutex.Unlock() - - post, exists := posts[postID] - if !exists { - return errors.New("post not found") - } - if post.Kind != KindTask { - return errors.New("only tasks can be claimed") - } - if post.Status != StatusOpen { - return errors.New("task is not open") - } - if post.AuthorID == workerID { - return errors.New("cannot claim your own task") - } - - post.WorkerID = workerID - post.Worker = worker - post.Status = StatusClaimed - post.ClaimedAt = time.Now() - - save() - return nil -} - -// DeliverTask submits work for review -func DeliverTask(postID, workerID, delivery string) error { - if delivery == "" { - return errors.New("delivery is required") - } - - mutex.Lock() - defer mutex.Unlock() - - post, exists := posts[postID] - if !exists { - return errors.New("post not found") - } - if post.Status != StatusClaimed { - return errors.New("task is not claimed") - } - if post.WorkerID != workerID { - return errors.New("only the assigned worker can deliver") - } - - post.Delivery = delivery - post.Status = StatusDelivered - post.DeliveredAt = time.Now() - - save() - return nil -} - -// AcceptTask accepts delivery -func AcceptTask(postID, authorID string) error { - mutex.Lock() - defer mutex.Unlock() - - post, exists := posts[postID] - if !exists { - return errors.New("post not found") - } - if post.Status != StatusDelivered { - return errors.New("task has not been delivered") - } - if post.AuthorID != authorID { - return errors.New("only the poster can accept delivery") - } - - post.Status = StatusCompleted - post.CompletedAt = time.Now() - - save() - return nil -} - -// CancelTask cancels an open or claimed task -func CancelTask(postID, authorID string) error { - mutex.Lock() - defer mutex.Unlock() - - post, exists := posts[postID] - if !exists { - return errors.New("post not found") - } - if post.AuthorID != authorID { - return errors.New("only the poster can cancel") - } - if post.Status == StatusCompleted { - return errors.New("completed tasks cannot be cancelled") - } - - post.Status = StatusCancelled - save() - return nil -} - -// ReleaseTask releases a claimed task back to open -func ReleaseTask(postID, authorID string) error { - mutex.Lock() - defer mutex.Unlock() - - post, exists := posts[postID] - if !exists { - return errors.New("post not found") - } - if post.AuthorID != authorID && post.WorkerID != authorID { - return errors.New("only the poster or worker can release") - } - if post.Status != StatusClaimed { - return errors.New("task is not claimed") - } - - post.WorkerID = "" - post.Worker = "" - post.Status = StatusOpen - post.ClaimedAt = time.Time{} - - save() - return nil -} - -// AddLog appends a log entry to a post and persists it. -func AddLog(postID, step, message string, credits int) { - mutex.Lock() - defer mutex.Unlock() - post, ok := posts[postID] - if !ok { - return - } - post.Log = append(post.Log, LogEntry{ - Step: step, - Message: message, - Credits: credits, - CreatedAt: time.Now(), - }) - post.Spent += credits - save() -} - -// SetStatus updates a task's status. -func SetStatus(postID, status string) { - mutex.Lock() - defer mutex.Unlock() - if post, ok := posts[postID]; ok { - post.Status = status - if status == StatusDelivered { - post.DeliveredAt = time.Now() - } else if status == StatusCompleted { - post.CompletedAt = time.Now() - } - save() - } -} - -// SetDelivery sets the delivery text and optional app slug for a task. -func SetDelivery(postID, delivery, appSlug string) { - mutex.Lock() - defer mutex.Unlock() - if post, ok := posts[postID]; ok { - post.Delivery = delivery - if appSlug != "" { - post.AppSlug = appSlug - } - save() - } -} - -// SpendCredits is wired by main.go to deduct credits from the user's wallet. -var SpendCredits func(userID string, amount int, operation string) error - -// BudgetRemaining returns how many credits are left in the budget. -func BudgetRemaining(postID string) int { - mutex.RLock() - defer mutex.RUnlock() - if post, ok := posts[postID]; ok { - return post.Cost - post.Spent - } - return 0 -} - -// AssignToAgent assigns an open task to the AI agent. -// Sets status to building and publishes a task_created event. -func AssignToAgent(postID, authorID string) error { - mutex.Lock() - post, exists := posts[postID] - if !exists { - mutex.Unlock() - return errors.New("post not found") - } - if post.Kind != KindTask { - mutex.Unlock() - return errors.New("only tasks can be assigned") - } - if post.Status != StatusOpen { - mutex.Unlock() - return errors.New("task is not open") - } - if post.AuthorID != authorID { - mutex.Unlock() - return errors.New("only the poster can assign to agent") - } - - post.WorkerID = "agent" - post.Worker = "agent" - post.Status = StatusClaimed - post.ClaimedAt = time.Now() - save() - mutex.Unlock() - - // Publish event — agent picks it up - event.Publish(event.Event{ - Type: event.EventTaskCreated, - Data: map[string]interface{}{ - "post_id": postID, - "author_id": authorID, - }, - }) - - return nil -} - -// RetryWithFeedback resets a delivered task and publishes a retry event. -func RetryWithFeedback(postID, feedback string) { - mutex.Lock() - post, ok := posts[postID] - if !ok { - mutex.Unlock() - return - } - post.Status = StatusClaimed - save() - mutex.Unlock() - - AddLog(postID, "retry", "Retrying with feedback: "+feedback, 0) - - event.Publish(event.Event{ - Type: event.EventTaskRetry, - Data: map[string]interface{}{ - "post_id": postID, - "feedback": feedback, - }, - }) -} - -// ResumeAgentWork re-publishes events for any in-progress agent tasks. -func ResumeAgentWork() { - mutex.RLock() - var inProgress []string - for _, p := range posts { - if p.WorkerID == "agent" && p.Status == StatusClaimed { - inProgress = append(inProgress, p.ID) - } - } - mutex.RUnlock() - - for _, id := range inProgress { - fmt.Printf("[work] Resuming agent task: %s\n", id) - event.Publish(event.Event{ - Type: event.EventTaskCreated, - Data: map[string]interface{}{"post_id": id}, - }) - } -} - -// DeletePost removes a work post by ID -func DeletePost(id string) error { - mutex.Lock() - defer mutex.Unlock() - if _, exists := posts[id]; !exists { - return errors.New("post not found") - } - delete(posts, id) - save() - return nil -} - -// GetPost returns a single post -func GetPost(id string) *Post { - mutex.RLock() - defer mutex.RUnlock() - return posts[id] -} - -// ListPosts returns posts filtered by kind and/or status, sorted newest first -func ListPosts(kind, status string, limit int) []*Post { - mutex.RLock() - defer mutex.RUnlock() - - var result []*Post - for _, p := range posts { - if kind != "" && p.Kind != kind { - continue - } - if status != "" && p.Status != status { - continue - } - result = append(result, p) - } - - // Sort newest first - for i := 0; i < len(result); i++ { - for j := i + 1; j < len(result); j++ { - if result[j].CreatedAt.After(result[i].CreatedAt) { - result[i], result[j] = result[j], result[i] - } - } - } - - if limit > 0 && len(result) > limit { - result = result[:limit] - } - - return result -}