refactor: simplify miniapp and helper packages
This commit is contained in:
parent
d6bc0c3f23
commit
8e3b931fc2
13 changed files with 338 additions and 323 deletions
|
|
@ -49,26 +49,45 @@ type DisposeResult struct {
|
||||||
CommitsAhead int // unique commits on branch (0 = safe to delete)
|
CommitsAhead int // unique commits on branch (0 = safe to delete)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindRepoRoot returns the git repository root for dir, or "" if not a git repo.
|
func gitOutputTrim(dir string, args ...string) (string, error) {
|
||||||
func FindRepoRoot(dir string) string {
|
cmd := exec.Command("git", args...)
|
||||||
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
|
||||||
cmd.Dir = dir
|
cmd.Dir = dir
|
||||||
out, err := cmd.Output()
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitCombinedOutputTrim(dir string, args ...string) (string, error) {
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return strings.TrimSpace(string(out)), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitRunOK(dir string, args ...string) bool {
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindRepoRoot returns the git repository root for dir, or "" if not a git repo.
|
||||||
|
func FindRepoRoot(dir string) string {
|
||||||
|
out, err := gitOutputTrim(dir, "rev-parse", "--show-toplevel")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(out))
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// CurrentBranch returns the current branch name, or "" on error.
|
// CurrentBranch returns the current branch name, or "" on error.
|
||||||
func CurrentBranch(dir string) string {
|
func CurrentBranch(dir string) string {
|
||||||
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
|
out, err := gitOutputTrim(dir, "rev-parse", "--abbrev-ref", "HEAD")
|
||||||
cmd.Dir = dir
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(out))
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
var unsafeBranchRe = regexp.MustCompile(`[^a-z0-9-]`)
|
var unsafeBranchRe = regexp.MustCompile(`[^a-z0-9-]`)
|
||||||
|
|
@ -111,9 +130,7 @@ func CreateWorktree(repoDir, worktreePath, branchName string) (*WorktreeInfo, er
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if branch already exists
|
// Check if branch already exists
|
||||||
checkCmd := exec.Command("git", "rev-parse", "--verify", branchName)
|
branchExists := gitRunOK(repoDir, "rev-parse", "--verify", branchName)
|
||||||
checkCmd.Dir = repoDir
|
|
||||||
branchExists := checkCmd.Run() == nil
|
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
if branchExists {
|
if branchExists {
|
||||||
|
|
@ -138,44 +155,36 @@ func CreateWorktree(repoDir, worktreePath, branchName string) (*WorktreeInfo, er
|
||||||
|
|
||||||
// HasUncommittedChanges returns true if the working tree has staged or unstaged changes.
|
// HasUncommittedChanges returns true if the working tree has staged or unstaged changes.
|
||||||
func HasUncommittedChanges(dir string) bool {
|
func HasUncommittedChanges(dir string) bool {
|
||||||
cmd := exec.Command("git", "status", "--porcelain")
|
out, err := gitOutputTrim(dir, "status", "--porcelain")
|
||||||
cmd.Dir = dir
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return len(strings.TrimSpace(string(out))) > 0
|
return out != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutoCommit stages all changes and commits with the given message.
|
// AutoCommit stages all changes and commits with the given message.
|
||||||
func AutoCommit(worktreePath, message string) error {
|
func AutoCommit(worktreePath, message string) error {
|
||||||
addCmd := exec.Command("git", "add", "-A")
|
if out, err := gitCombinedOutputTrim(worktreePath, "add", "-A"); err != nil {
|
||||||
addCmd.Dir = worktreePath
|
return fmt.Errorf("git add: %s: %w", out, err)
|
||||||
if out, err := addCmd.CombinedOutput(); err != nil {
|
|
||||||
return fmt.Errorf("git add: %s: %w", strings.TrimSpace(string(out)), err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
commitCmd := exec.Command("git", "commit", "-m", message, "--allow-empty-message")
|
if out, err := gitCombinedOutputTrim(worktreePath, "commit", "-m", message, "--allow-empty-message"); err != nil {
|
||||||
commitCmd.Dir = worktreePath
|
|
||||||
if out, err := commitCmd.CombinedOutput(); err != nil {
|
|
||||||
// "nothing to commit" is not a real error
|
// "nothing to commit" is not a real error
|
||||||
if strings.Contains(string(out), "nothing to commit") {
|
if strings.Contains(out, "nothing to commit") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("git commit: %s: %w", strings.TrimSpace(string(out)), err)
|
return fmt.Errorf("git commit: %s: %w", out, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommitsAhead returns the number of commits on branch that are not on base.
|
// CommitsAhead returns the number of commits on branch that are not on base.
|
||||||
func CommitsAhead(repoDir, base, branch string) int {
|
func CommitsAhead(repoDir, base, branch string) int {
|
||||||
cmd := exec.Command("git", "rev-list", "--count", base+".."+branch)
|
out, err := gitOutputTrim(repoDir, "rev-list", "--count", base+".."+branch)
|
||||||
cmd.Dir = repoDir
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
|
n, _ := strconv.Atoi(out)
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,13 +369,11 @@ func DisposeManagedWorktree(repoDir, worktreesDir, name, baseBranch string) (Dis
|
||||||
|
|
||||||
// WorktreeStatusShort returns "git status --short" output for a worktree.
|
// WorktreeStatusShort returns "git status --short" output for a worktree.
|
||||||
func WorktreeStatusShort(worktreePath string) (string, error) {
|
func WorktreeStatusShort(worktreePath string) (string, error) {
|
||||||
cmd := exec.Command("git", "status", "--short")
|
out, err := gitCombinedOutputTrim(worktreePath, "status", "--short")
|
||||||
cmd.Dir = worktreePath
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("git status --short: %s: %w", strings.TrimSpace(string(out)), err)
|
return "", fmt.Errorf("git status --short: %s: %w", out, err)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(out)), nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorktreeRecentLog returns recent oneline commits for a worktree branch.
|
// WorktreeRecentLog returns recent oneline commits for a worktree branch.
|
||||||
|
|
@ -374,24 +381,20 @@ func WorktreeRecentLog(worktreePath string, n int) (string, error) {
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
n = 10
|
n = 10
|
||||||
}
|
}
|
||||||
cmd := exec.Command("git", "log", "--oneline", fmt.Sprintf("-%d", n))
|
out, err := gitCombinedOutputTrim(worktreePath, "log", "--oneline", fmt.Sprintf("-%d", n))
|
||||||
cmd.Dir = worktreePath
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("git log --oneline: %s: %w", strings.TrimSpace(string(out)), err)
|
return "", fmt.Errorf("git log --oneline: %s: %w", out, err)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(out)), nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorktreeDiffStat returns a compact diff stat for a worktree.
|
// WorktreeDiffStat returns a compact diff stat for a worktree.
|
||||||
func WorktreeDiffStat(worktreePath string) (string, error) {
|
func WorktreeDiffStat(worktreePath string) (string, error) {
|
||||||
cmd := exec.Command("git", "diff", "--stat")
|
out, err := gitCombinedOutputTrim(worktreePath, "diff", "--stat")
|
||||||
cmd.Dir = worktreePath
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("git diff --stat: %s: %w", strings.TrimSpace(string(out)), err)
|
return "", fmt.Errorf("git diff --stat: %s: %w", out, err)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(out)), nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectDefaultBranch returns the preferred base branch name for merges/dispose.
|
// DetectDefaultBranch returns the preferred base branch name for merges/dispose.
|
||||||
|
|
@ -404,10 +407,7 @@ func DetectDefaultBranch(repoDir string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try origin/HEAD -> origin/<branch>
|
// Try origin/HEAD -> origin/<branch>
|
||||||
cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD")
|
if ref, err := gitOutputTrim(repoDir, "symbolic-ref", "refs/remotes/origin/HEAD"); err == nil {
|
||||||
cmd.Dir = repoDir
|
|
||||||
if out, err := cmd.Output(); err == nil {
|
|
||||||
ref := strings.TrimSpace(string(out))
|
|
||||||
if idx := strings.LastIndex(ref, "/"); idx >= 0 && idx < len(ref)-1 {
|
if idx := strings.LastIndex(ref, "/"); idx >= 0 && idx < len(ref)-1 {
|
||||||
return ref[idx+1:]
|
return ref[idx+1:]
|
||||||
}
|
}
|
||||||
|
|
@ -559,13 +559,11 @@ func buildManagedWorktree(repoDir, name, wtPath string) ManagedWorktree {
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastCommitInfo(dir string) (hash, subject, age string) {
|
func lastCommitInfo(dir string) (hash, subject, age string) {
|
||||||
cmd := exec.Command("git", "log", "-1", "--pretty=format:%h\x1f%s\x1f%cr")
|
out, err := gitOutputTrim(dir, "log", "-1", "--pretty=format:%h\x1f%s\x1f%cr")
|
||||||
cmd.Dir = dir
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", ""
|
return "", "", ""
|
||||||
}
|
}
|
||||||
parts := strings.SplitN(strings.TrimSpace(string(out)), "\x1f", 3)
|
parts := strings.SplitN(out, "\x1f", 3)
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
hash = parts[0]
|
hash = parts[0]
|
||||||
}
|
}
|
||||||
|
|
@ -579,28 +577,22 @@ func lastCommitInfo(dir string) (hash, subject, age string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func localBranchExists(repoDir, name string) bool {
|
func localBranchExists(repoDir, name string) bool {
|
||||||
cmd := exec.Command("git", "rev-parse", "--verify", "refs/heads/"+name)
|
return gitRunOK(repoDir, "rev-parse", "--verify", "refs/heads/"+name)
|
||||||
cmd.Dir = repoDir
|
|
||||||
return cmd.Run() == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkoutBranch(repoDir, branch string) error {
|
func checkoutBranch(repoDir, branch string) error {
|
||||||
cmd := exec.Command("git", "checkout", branch)
|
if out, err := gitCombinedOutputTrim(repoDir, "checkout", branch); err != nil {
|
||||||
cmd.Dir = repoDir
|
return fmt.Errorf("git checkout %s: %s: %w", branch, out, err)
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
return fmt.Errorf("git checkout %s: %s: %w", branch, strings.TrimSpace(string(out)), err)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func listGitWorktreePaths(repoDir string) ([]string, error) {
|
func listGitWorktreePaths(repoDir string) ([]string, error) {
|
||||||
cmd := exec.Command("git", "worktree", "list", "--porcelain")
|
out, err := gitOutputTrim(repoDir, "worktree", "list", "--porcelain")
|
||||||
cmd.Dir = repoDir
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
lines := strings.Split(string(out), "\n")
|
lines := strings.Split(out, "\n")
|
||||||
paths := make([]string, 0)
|
paths := make([]string, 0)
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if !strings.HasPrefix(line, "worktree ") {
|
if !strings.HasPrefix(line, "worktree ") {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ type Cache struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func currentTimestamp() string {
|
||||||
|
return time.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
// Open opens (or creates) a media cache database at dbPath.
|
// Open opens (or creates) a media cache database at dbPath.
|
||||||
func Open(dbPath string) (*Cache, error) {
|
func Open(dbPath string) (*Cache, error) {
|
||||||
connStr := "file:" + dbPath + "?_journal_mode=WAL&_busy_timeout=5000"
|
connStr := "file:" + dbPath + "?_journal_mode=WAL&_busy_timeout=5000"
|
||||||
|
|
@ -70,18 +74,13 @@ func (c *Cache) Get(hash, entryType string) (string, bool) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
// Update accessed_at
|
c.touchAccessed(hash, entryType)
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
_, _ = c.db.Exec(
|
|
||||||
`UPDATE media_cache SET accessed_at = ? WHERE hash = ? AND type = ?`,
|
|
||||||
now, hash, entryType,
|
|
||||||
)
|
|
||||||
return result, true
|
return result, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put stores a result in the cache.
|
// Put stores a result in the cache.
|
||||||
func (c *Cache) Put(hash, entryType, result string) error {
|
func (c *Cache) Put(hash, entryType, result string) error {
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := currentTimestamp()
|
||||||
_, err := c.db.Exec(
|
_, err := c.db.Exec(
|
||||||
`INSERT INTO media_cache (hash, type, result, created_at, accessed_at)
|
`INSERT INTO media_cache (hash, type, result, created_at, accessed_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
|
@ -110,17 +109,13 @@ func (c *Cache) GetEntry(hash, entryType string) (Entry, bool) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Entry{}, false
|
return Entry{}, false
|
||||||
}
|
}
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
c.touchAccessed(hash, entryType)
|
||||||
_, _ = c.db.Exec(
|
|
||||||
`UPDATE media_cache SET accessed_at = ? WHERE hash = ? AND type = ?`,
|
|
||||||
now, hash, entryType,
|
|
||||||
)
|
|
||||||
return e, true
|
return e, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// PutEntry stores a full entry in the cache.
|
// PutEntry stores a full entry in the cache.
|
||||||
func (c *Cache) PutEntry(hash, entryType string, entry Entry) error {
|
func (c *Cache) PutEntry(hash, entryType string, entry Entry) error {
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := currentTimestamp()
|
||||||
_, err := c.db.Exec(
|
_, err := c.db.Exec(
|
||||||
`INSERT INTO media_cache (hash, type, result, file_path, pages, created_at, accessed_at)
|
`INSERT INTO media_cache (hash, type, result, file_path, pages, created_at, accessed_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
|
@ -188,6 +183,13 @@ func (c *Cache) Prune(ttl time.Duration) (int64, error) {
|
||||||
return res.RowsAffected()
|
return res.RowsAffected()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Cache) touchAccessed(hash, entryType string) {
|
||||||
|
_, _ = c.db.Exec(
|
||||||
|
`UPDATE media_cache SET accessed_at = ? WHERE hash = ? AND type = ?`,
|
||||||
|
currentTimestamp(), hash, entryType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// HashData computes a fast FNV-1a 64-bit hash of the given data,
|
// HashData computes a fast FNV-1a 64-bit hash of the given data,
|
||||||
// returned as a hex string.
|
// returned as a hex string.
|
||||||
func HashData(data []byte) string {
|
func HashData(data []byte) string {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -61,7 +60,7 @@ func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) {
|
||||||
func (h *Handler) apiWorktrees(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiWorktrees(w http.ResponseWriter, r *http.Request) {
|
||||||
repoRoot := git.FindRepoRoot(h.workspace)
|
repoRoot := git.FindRepoRoot(h.workspace)
|
||||||
if repoRoot == "" {
|
if repoRoot == "" {
|
||||||
http.Error(w, `{"error":"workspace is not a git repository"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "workspace is not a git repository")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
worktreesDir := filepath.Join(h.workspace, ".worktrees")
|
worktreesDir := filepath.Join(h.workspace, ".worktrees")
|
||||||
|
|
@ -70,26 +69,20 @@ func (h *Handler) apiWorktrees(w http.ResponseWriter, r *http.Request) {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
items, err := git.ListManagedWorktrees(repoRoot, worktreesDir)
|
items, err := git.ListManagedWorktrees(repoRoot, worktreesDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"failed to list worktrees"}`, http.StatusInternalServerError)
|
writeJSONError(w, http.StatusInternalServerError, "failed to list worktrees")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, items)
|
writeJSON(w, items)
|
||||||
|
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Force bool `json:"force"`
|
Force bool `json:"force"`
|
||||||
BaseBranch string `json:"base_branch"`
|
BaseBranch string `json:"base_branch"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &req); err != nil {
|
if err := decodeJSONBody(r, 4096, &req); err != nil {
|
||||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,7 +90,7 @@ func (h *Handler) apiWorktrees(w http.ResponseWriter, r *http.Request) {
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
req.BaseBranch = strings.TrimSpace(req.BaseBranch)
|
req.BaseBranch = strings.TrimSpace(req.BaseBranch)
|
||||||
if req.Action == "" || req.Name == "" {
|
if req.Action == "" || req.Name == "" {
|
||||||
http.Error(w, `{"error":"action and name are required"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "action and name are required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,7 +101,7 @@ func (h *Handler) apiWorktrees(w http.ResponseWriter, r *http.Request) {
|
||||||
if writeWorktreeAPIError(w, err) {
|
if writeWorktreeAPIError(w, err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, `{"error":"merge failed"}`, http.StatusInternalServerError)
|
writeJSONError(w, http.StatusInternalServerError, "merge failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, map[string]any{
|
writeJSON(w, map[string]any{
|
||||||
|
|
@ -125,15 +118,11 @@ func (h *Handler) apiWorktrees(w http.ResponseWriter, r *http.Request) {
|
||||||
if writeWorktreeAPIError(w, err) {
|
if writeWorktreeAPIError(w, err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, `{"error":"failed to inspect worktree"}`, http.StatusInternalServerError)
|
writeJSONError(w, http.StatusInternalServerError, "failed to inspect worktree")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if wt.HasUncommitted && !req.Force {
|
if wt.HasUncommitted && !req.Force {
|
||||||
http.Error(
|
writeJSONError(w, http.StatusConflict, "worktree has uncommitted changes; retry with force=true")
|
||||||
w,
|
|
||||||
`{"error":"worktree has uncommitted changes; retry with force=true"}`,
|
|
||||||
http.StatusConflict,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,7 +131,7 @@ func (h *Handler) apiWorktrees(w http.ResponseWriter, r *http.Request) {
|
||||||
if writeWorktreeAPIError(w, err) {
|
if writeWorktreeAPIError(w, err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, `{"error":"dispose failed"}`, http.StatusInternalServerError)
|
writeJSONError(w, http.StatusInternalServerError, "dispose failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, map[string]any{
|
writeJSON(w, map[string]any{
|
||||||
|
|
@ -153,11 +142,11 @@ func (h *Handler) apiWorktrees(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, `{"error":"unknown action"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "unknown action")
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
writeMethodNotAllowed(w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,26 +163,20 @@ func (h *Handler) apiSessionGraph(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
writeMethodNotAllowed(w)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &req); err != nil || req.Command == "" {
|
if err := decodeJSONBody(r, 4096, &req); err != nil || req.Command == "" {
|
||||||
http.Error(w, `{"error":"missing command"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "missing command")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(req.Command, "/") {
|
if !strings.HasPrefix(req.Command, "/") {
|
||||||
http.Error(w, `{"error":"command must start with /"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "command must start with /")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,7 +184,7 @@ func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||||
initData := r.URL.Query().Get("initData")
|
initData := r.URL.Query().Get("initData")
|
||||||
userID, chatID := extractUserFromInitData(initData)
|
userID, chatID := extractUserFromInitData(initData)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
http.Error(w, `{"error":"cannot identify user"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "cannot identify user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,7 +195,7 @@ func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||||
func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
flusher, ok := w.(http.Flusher)
|
flusher, ok := w.(http.Flusher)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, `{"error":"streaming not supported"}`, http.StatusInternalServerError)
|
writeJSONError(w, http.StatusInternalServerError, "streaming not supported")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rc := http.NewResponseController(w)
|
rc := http.NewResponseController(w)
|
||||||
|
|
@ -288,18 +271,13 @@ func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeWorktreeAPIError(w http.ResponseWriter, err error) bool {
|
func writeWorktreeAPIError(w http.ResponseWriter, err error) bool {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, git.ErrInvalidWorktreeName):
|
case errors.Is(err, git.ErrInvalidWorktreeName):
|
||||||
http.Error(w, `{"error":"invalid worktree name"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "invalid worktree name")
|
||||||
return true
|
return true
|
||||||
case errors.Is(err, git.ErrWorktreeNotFound):
|
case errors.Is(err, git.ErrWorktreeNotFound):
|
||||||
http.Error(w, `{"error":"worktree not found"}`, http.StatusNotFound)
|
writeJSONError(w, http.StatusNotFound, "worktree not found")
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
package miniapp
|
package miniapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -24,7 +22,7 @@ func (h *Handler) SetResearchFocus(ft *research.FocusTracker) {
|
||||||
// apiResearch handles GET /miniapp/api/research (list) and POST (create).
|
// apiResearch handles GET /miniapp/api/research (list) and POST (create).
|
||||||
func (h *Handler) apiResearch(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiResearch(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.researchStore == nil {
|
if h.researchStore == nil {
|
||||||
http.Error(w, `{"error":"research store not available"}`, http.StatusServiceUnavailable)
|
writeJSONError(w, http.StatusServiceUnavailable, "research store not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,7 +32,7 @@ func (h *Handler) apiResearch(w http.ResponseWriter, r *http.Request) {
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
h.apiResearchCreate(w, r)
|
h.apiResearchCreate(w, r)
|
||||||
default:
|
default:
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
writeMethodNotAllowed(w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,7 +78,7 @@ func (h *Handler) apiResearchList(w http.ResponseWriter, r *http.Request) {
|
||||||
statusFilter := r.URL.Query().Get("status")
|
statusFilter := r.URL.Query().Get("status")
|
||||||
tasks, err := h.researchStore.ListTasks(research.TaskStatus(statusFilter))
|
tasks, err := h.researchStore.ListTasks(research.TaskStatus(statusFilter))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,12 +100,12 @@ func (h *Handler) apiResearchCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Interval string `json:"interval"`
|
Interval string `json:"interval"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
if err := decodeJSONBody(r, 1<<16, &req); err != nil {
|
||||||
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Title) == "" {
|
if strings.TrimSpace(req.Title) == "" {
|
||||||
http.Error(w, `{"error":"title is required"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "title is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,7 +115,7 @@ func (h *Handler) apiResearchCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
strings.TrimSpace(req.Interval),
|
strings.TrimSpace(req.Interval),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,7 +126,7 @@ func (h *Handler) apiResearchCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
// apiResearchDetail handles /miniapp/api/research/{id} and /miniapp/api/research/{id}/doc/{docId}.
|
// apiResearchDetail handles /miniapp/api/research/{id} and /miniapp/api/research/{id}/doc/{docId}.
|
||||||
func (h *Handler) apiResearchDetail(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiResearchDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.researchStore == nil {
|
if h.researchStore == nil {
|
||||||
http.Error(w, `{"error":"research store not available"}`, http.StatusServiceUnavailable)
|
writeJSONError(w, http.StatusServiceUnavailable, "research store not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,7 +143,7 @@ func (h *Handler) apiResearchDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
h.apiResearchTaskAction(w, r, taskID)
|
h.apiResearchTaskAction(w, r, taskID)
|
||||||
default:
|
default:
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
writeMethodNotAllowed(w)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -153,7 +151,7 @@ func (h *Handler) apiResearchDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
if len(parts) == 3 && parts[1] == "doc" && parts[2] != "" {
|
if len(parts) == 3 && parts[1] == "doc" && parts[2] != "" {
|
||||||
// /miniapp/api/research/{id}/doc/{docId}
|
// /miniapp/api/research/{id}/doc/{docId}
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
writeMethodNotAllowed(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.apiResearchGetDoc(w, parts[0], parts[2])
|
h.apiResearchGetDoc(w, parts[0], parts[2])
|
||||||
|
|
@ -182,7 +180,7 @@ type researchTaskDetailResponse struct {
|
||||||
func (h *Handler) apiResearchGetTask(w http.ResponseWriter, taskID string) {
|
func (h *Handler) apiResearchGetTask(w http.ResponseWriter, taskID string) {
|
||||||
task, err := h.researchStore.GetTask(taskID)
|
task, err := h.researchStore.GetTask(taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"task not found"}`, http.StatusNotFound)
|
writeJSONError(w, http.StatusNotFound, "task not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,36 +217,36 @@ func (h *Handler) apiResearchTaskAction(w http.ResponseWriter, r *http.Request,
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Interval string `json:"interval"`
|
Interval string `json:"interval"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
if err := decodeJSONBody(r, 1<<16, &req); err != nil {
|
||||||
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch req.Action {
|
switch req.Action {
|
||||||
case "cancel":
|
case "cancel":
|
||||||
if err := h.researchStore.SetTaskStatus(taskID, research.StatusCanceled); err != nil {
|
if err := h.researchStore.SetTaskStatus(taskID, research.StatusCanceled); err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "reopen":
|
case "reopen":
|
||||||
if err := h.researchStore.SetTaskStatus(taskID, research.StatusPending); err != nil {
|
if err := h.researchStore.SetTaskStatus(taskID, research.StatusPending); err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "activate":
|
case "activate":
|
||||||
if err := h.researchStore.SetTaskStatus(taskID, research.StatusActive); err != nil {
|
if err := h.researchStore.SetTaskStatus(taskID, research.StatusActive); err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "complete":
|
case "complete":
|
||||||
if err := h.researchStore.SetTaskStatus(taskID, research.StatusCompleted); err != nil {
|
if err := h.researchStore.SetTaskStatus(taskID, research.StatusCompleted); err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "update":
|
case "update":
|
||||||
task, err := h.researchStore.GetTask(taskID)
|
task, err := h.researchStore.GetTask(taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"task not found"}`, http.StatusNotFound)
|
writeJSONError(w, http.StatusNotFound, "task not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
title := req.Title
|
title := req.Title
|
||||||
|
|
@ -260,20 +258,20 @@ func (h *Handler) apiResearchTaskAction(w http.ResponseWriter, r *http.Request,
|
||||||
desc = task.Description
|
desc = task.Description
|
||||||
}
|
}
|
||||||
if err := h.researchStore.UpdateTask(taskID, title, desc); err != nil {
|
if err := h.researchStore.UpdateTask(taskID, title, desc); err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "set_interval":
|
case "set_interval":
|
||||||
if req.Interval == "" {
|
if req.Interval == "" {
|
||||||
http.Error(w, `{"error":"interval is required"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "interval is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.researchStore.SetInterval(taskID, req.Interval); err != nil {
|
if err := h.researchStore.SetInterval(taskID, req.Interval); err != nil {
|
||||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
http.Error(w, `{"error":"unknown action"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "unknown action")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,7 +282,7 @@ func (h *Handler) apiResearchTaskAction(w http.ResponseWriter, r *http.Request,
|
||||||
func (h *Handler) apiResearchGetDoc(w http.ResponseWriter, taskID, docID string) {
|
func (h *Handler) apiResearchGetDoc(w http.ResponseWriter, taskID, docID string) {
|
||||||
docs, err := h.researchStore.ListDocuments(taskID)
|
docs, err := h.researchStore.ListDocuments(taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"failed to list documents"}`, http.StatusInternalServerError)
|
writeJSONError(w, http.StatusInternalServerError, "failed to list documents")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,7 +294,7 @@ func (h *Handler) apiResearchGetDoc(w http.ResponseWriter, taskID, docID string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if found == nil {
|
if found == nil {
|
||||||
http.Error(w, `{"error":"document not found"}`, http.StatusNotFound)
|
writeJSONError(w, http.StatusNotFound, "document not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -308,7 +306,7 @@ func (h *Handler) apiResearchGetDoc(w http.ResponseWriter, taskID, docID string)
|
||||||
|
|
||||||
content, err := os.ReadFile(absPath)
|
content, err := os.ReadFile(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"failed to read document"}`, http.StatusInternalServerError)
|
writeJSONError(w, http.StatusInternalServerError, "failed to read document")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,7 +322,7 @@ func (h *Handler) apiResearchGetDoc(w http.ResponseWriter, taskID, docID string)
|
||||||
// GET returns the current focus state; POST sets focus/unfocus for a task.
|
// GET returns the current focus state; POST sets focus/unfocus for a task.
|
||||||
func (h *Handler) apiResearchFocus(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiResearchFocus(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.researchFocus == nil {
|
if h.researchFocus == nil {
|
||||||
http.Error(w, `{"error":"research focus not available"}`, http.StatusServiceUnavailable)
|
writeJSONError(w, http.StatusServiceUnavailable, "research focus not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -334,7 +332,7 @@ func (h *Handler) apiResearchFocus(w http.ResponseWriter, r *http.Request) {
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
h.apiResearchFocusSet(w, r)
|
h.apiResearchFocusSet(w, r)
|
||||||
default:
|
default:
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
writeMethodNotAllowed(w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -351,20 +349,20 @@ func (h *Handler) apiResearchFocusSet(w http.ResponseWriter, r *http.Request) {
|
||||||
Action string `json:"action"` // "recall" or "forget"
|
Action string `json:"action"` // "recall" or "forget"
|
||||||
TaskID string `json:"task_id"`
|
TaskID string `json:"task_id"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&req); err != nil {
|
if err := decodeJSONBody(r, 1<<14, &req); err != nil {
|
||||||
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch req.Action {
|
switch req.Action {
|
||||||
case "recall":
|
case "recall":
|
||||||
if req.TaskID == "" {
|
if req.TaskID == "" {
|
||||||
http.Error(w, `{"error":"task_id is required"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "task_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
task, err := h.researchStore.GetTask(req.TaskID)
|
task, err := h.researchStore.GetTask(req.TaskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"task not found"}`, http.StatusNotFound)
|
writeJSONError(w, http.StatusNotFound, "task not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.researchFocus.Focus(task.ID, task.Title)
|
h.researchFocus.Focus(task.ID, task.Title)
|
||||||
|
|
@ -375,7 +373,7 @@ func (h *Handler) apiResearchFocusSet(w http.ResponseWriter, r *http.Request) {
|
||||||
h.researchFocus.Unfocus(req.TaskID)
|
h.researchFocus.Unfocus(req.TaskID)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
http.Error(w, `{"error":"action must be recall or forget"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "action must be recall or forget")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,17 +21,17 @@ func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
initData := r.URL.Query().Get("initData")
|
initData := r.URL.Query().Get("initData")
|
||||||
if initData == "" {
|
if initData == "" {
|
||||||
http.Error(w, `{"error":"missing initData"}`, http.StatusUnauthorized)
|
writeJSONError(w, http.StatusUnauthorized, "missing initData")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ValidateInitData(initData, h.botToken) {
|
if !ValidateInitData(initData, h.botToken) {
|
||||||
http.Error(w, `{"error":"invalid initData"}`, http.StatusUnauthorized)
|
writeJSONError(w, http.StatusUnauthorized, "invalid initData")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(h.allowList) > 0 {
|
if len(h.allowList) > 0 {
|
||||||
userID, _ := extractUserFromInitData(initData)
|
userID, _ := extractUserFromInitData(initData)
|
||||||
if userID == "" || !isAllowed(userID, h.allowList) {
|
if userID == "" || !isAllowed(userID, h.allowList) {
|
||||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
writeJSONError(w, http.StatusForbidden, "forbidden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,8 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -45,9 +42,7 @@ func (h *Handler) RegisterDevTarget(name, target string) (string, error) {
|
||||||
id := strconv.Itoa(h.devNextID)
|
id := strconv.Itoa(h.devNextID)
|
||||||
|
|
||||||
h.devTargets[id] = &DevTarget{ID: id, Name: name, Target: target}
|
h.devTargets[id] = &DevTarget{ID: id, Name: name, Target: target}
|
||||||
if h.notifier != nil {
|
h.notifyStateChanged()
|
||||||
h.notifier.Notify()
|
|
||||||
}
|
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,13 +59,9 @@ func (h *Handler) UnregisterDevTarget(id string) error {
|
||||||
delete(h.devTargets, id)
|
delete(h.devTargets, id)
|
||||||
|
|
||||||
if h.devActiveID == id {
|
if h.devActiveID == id {
|
||||||
h.devActiveID = ""
|
h.clearActiveDevTargetLocked()
|
||||||
h.devTarget = nil
|
|
||||||
h.devProxy = nil
|
|
||||||
}
|
|
||||||
if h.notifier != nil {
|
|
||||||
h.notifier.Notify()
|
|
||||||
}
|
}
|
||||||
|
h.notifyStateChanged()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,59 +77,13 @@ func (h *Handler) ActivateDevTarget(id string) error {
|
||||||
return fmt.Errorf("target %q not found", id)
|
return fmt.Errorf("target %q not found", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := url.Parse(dt.Target)
|
u, proxy, err := buildDevProxy(dt)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix IPv6: resolve "localhost" to 127.0.0.1 to avoid connection refused on systems
|
|
||||||
// where localhost resolves to [::1] but the dev server only listens on IPv4.
|
|
||||||
if u.Hostname() == "localhost" {
|
|
||||||
u.Host = net.JoinHostPort("127.0.0.1", u.Port())
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(u)
|
|
||||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
|
||||||
// Prevent browser/WebView from caching dev proxy responses (CSS, JS, etc.)
|
|
||||||
resp.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
||||||
resp.Header.Del("ETag")
|
|
||||||
resp.Header.Del("Last-Modified")
|
|
||||||
|
|
||||||
ct := resp.Header.Get("Content-Type")
|
|
||||||
if !strings.Contains(ct, "text/html") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
resp.Body.Close()
|
|
||||||
modified := injectDevProxyScript(body)
|
|
||||||
resp.Body = io.NopCloser(bytes.NewReader(modified))
|
|
||||||
resp.ContentLength = int64(len(modified))
|
|
||||||
resp.Header.Set("Content-Length", strconv.Itoa(len(modified)))
|
|
||||||
resp.Header.Del("Content-Encoding")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
|
||||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
|
||||||
<html><head><style>
|
|
||||||
body{background:#1c1c1e;color:#fff;font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
||||||
.box{text-align:center;padding:32px}
|
|
||||||
h2{margin:0 0 12px;font-size:20px;font-weight:600}
|
|
||||||
p{color:#8e8e93;font-size:14px;margin:0}
|
|
||||||
</style></head><body><div class="box"><h2>Cannot connect</h2><p>%s</p><p style="margin-top:8px;font-size:12px">Target: %s</p></div></body></html>`,
|
|
||||||
escapeHTMLString(err.Error()), escapeHTMLString(dt.Target))
|
|
||||||
}
|
|
||||||
|
|
||||||
h.devTarget = u
|
h.setActiveDevTargetLocked(id, u, proxy)
|
||||||
h.devProxy = proxy
|
h.notifyStateChanged()
|
||||||
h.devActiveID = id
|
|
||||||
if h.notifier != nil {
|
|
||||||
h.notifier.Notify()
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,12 +94,8 @@ func (h *Handler) DeactivateDevTarget() error {
|
||||||
h.devMu.Lock()
|
h.devMu.Lock()
|
||||||
defer h.devMu.Unlock()
|
defer h.devMu.Unlock()
|
||||||
|
|
||||||
h.devActiveID = ""
|
h.clearActiveDevTargetLocked()
|
||||||
h.devTarget = nil
|
h.notifyStateChanged()
|
||||||
h.devProxy = nil
|
|
||||||
if h.notifier != nil {
|
|
||||||
h.notifier.Notify()
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,14 +117,7 @@ func (h *Handler) GetDevTarget() string {
|
||||||
func (h *Handler) ListDevTargets() []DevTarget {
|
func (h *Handler) ListDevTargets() []DevTarget {
|
||||||
h.devMu.RLock()
|
h.devMu.RLock()
|
||||||
defer h.devMu.RUnlock()
|
defer h.devMu.RUnlock()
|
||||||
|
return h.sortedDevTargetsLocked()
|
||||||
targets := make([]DevTarget, 0, len(h.devTargets))
|
|
||||||
for _, dt := range h.devTargets {
|
|
||||||
targets = append(targets, *dt)
|
|
||||||
}
|
|
||||||
// Sort by ID for stable order
|
|
||||||
sort.Slice(targets, func(i, j int) bool { return targets[i].ID < targets[j].ID })
|
|
||||||
return targets
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// devProxyScript is the JavaScript injected into HTML responses from the dev proxy.
|
// devProxyScript is the JavaScript injected into HTML responses from the dev proxy.
|
||||||
|
|
@ -301,50 +235,45 @@ func (h *Handler) apiDev(w http.ResponseWriter, r *http.Request) {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
writeJSON(w, h.devStatus())
|
writeJSON(w, h.devStatus())
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &req); err != nil {
|
if err := decodeJSONBody(r, 4096, &req); err != nil {
|
||||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch req.Action {
|
switch req.Action {
|
||||||
case "activate":
|
case "activate":
|
||||||
if req.ID == "" {
|
if req.ID == "" {
|
||||||
writeJSON(w, map[string]any{"error": "id is required"})
|
writeJSONError(w, http.StatusBadRequest, "id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.ActivateDevTarget(req.ID); err != nil {
|
if err := h.ActivateDevTarget(req.ID); err != nil {
|
||||||
writeJSON(w, map[string]any{"error": err.Error()})
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "deactivate":
|
case "deactivate":
|
||||||
if err := h.DeactivateDevTarget(); err != nil {
|
if err := h.DeactivateDevTarget(); err != nil {
|
||||||
writeJSON(w, map[string]any{"error": err.Error()})
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "unregister":
|
case "unregister":
|
||||||
if req.ID == "" {
|
if req.ID == "" {
|
||||||
writeJSON(w, map[string]any{"error": "id is required"})
|
writeJSONError(w, http.StatusBadRequest, "id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.UnregisterDevTarget(req.ID); err != nil {
|
if err := h.UnregisterDevTarget(req.ID); err != nil {
|
||||||
writeJSON(w, map[string]any{"error": err.Error()})
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
writeJSON(w, map[string]any{"error": "unknown action"})
|
writeJSONError(w, http.StatusBadRequest, "unknown action")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, h.devStatus())
|
writeJSON(w, h.devStatus())
|
||||||
default:
|
default:
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
writeMethodNotAllowed(w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -354,7 +283,7 @@ func (h *Handler) serveDevProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
h.devMu.RUnlock()
|
h.devMu.RUnlock()
|
||||||
|
|
||||||
if proxy == nil {
|
if proxy == nil {
|
||||||
http.Error(w, "dev proxy not configured", http.StatusServiceUnavailable)
|
writeJSONError(w, http.StatusServiceUnavailable, "dev proxy not configured")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,30 +308,24 @@ func (h *Handler) devStatus() map[string]any {
|
||||||
target = h.devTargets[h.devActiveID].Target // original URL before IPv6 rewrite
|
target = h.devTargets[h.devActiveID].Target // original URL before IPv6 rewrite
|
||||||
}
|
}
|
||||||
|
|
||||||
targets := make([]DevTarget, 0, len(h.devTargets))
|
|
||||||
for _, dt := range h.devTargets {
|
|
||||||
targets = append(targets, *dt)
|
|
||||||
}
|
|
||||||
sort.Slice(targets, func(i, j int) bool { return targets[i].ID < targets[j].ID })
|
|
||||||
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"active": active,
|
"active": active,
|
||||||
"active_id": h.devActiveID,
|
"active_id": h.devActiveID,
|
||||||
"target": target,
|
"target": target,
|
||||||
"targets": targets,
|
"targets": h.sortedDevTargetsLocked(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// apiDevConsole receives console output from dev preview iframes.
|
// apiDevConsole receives console output from dev preview iframes.
|
||||||
func (h *Handler) apiDevConsole(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiDevConsole(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
writeMethodNotAllowed(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only accept console posts when dev proxy is active
|
// Only accept console posts when dev proxy is active
|
||||||
if h.GetDevTarget() == "" {
|
if h.GetDevTarget() == "" {
|
||||||
http.Error(w, `{"error":"not available"}`, http.StatusNotFound)
|
writeJSONError(w, http.StatusNotFound, "not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -417,13 +340,13 @@ func (h *Handler) apiDevConsole(w http.ResponseWriter, r *http.Request) {
|
||||||
over := h.consoleReqCount > 10
|
over := h.consoleReqCount > 10
|
||||||
h.consoleMu.Unlock()
|
h.consoleMu.Unlock()
|
||||||
if over {
|
if over {
|
||||||
http.Error(w, `{"error":"rate limit"}`, http.StatusTooManyRequests)
|
writeJSONError(w, http.StatusTooManyRequests, "rate limit")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(r.Body, 32*1024))
|
body, err := io.ReadAll(io.LimitReader(r.Body, 32*1024))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "bad request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -432,7 +355,7 @@ func (h *Handler) apiDevConsole(w http.ResponseWriter, r *http.Request) {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &entries); err != nil {
|
if err := json.Unmarshal(body, &entries); err != nil {
|
||||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
92
pkg/miniapp/dev_helpers.go
Normal file
92
pkg/miniapp/dev_helpers.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package miniapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) notifyStateChanged() {
|
||||||
|
if h.notifier != nil {
|
||||||
|
h.notifier.Notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) clearActiveDevTargetLocked() {
|
||||||
|
h.devActiveID = ""
|
||||||
|
h.devTarget = nil
|
||||||
|
h.devProxy = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) setActiveDevTargetLocked(id string, target *url.URL, proxy *httputil.ReverseProxy) {
|
||||||
|
h.devActiveID = id
|
||||||
|
h.devTarget = target
|
||||||
|
h.devProxy = proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) sortedDevTargetsLocked() []DevTarget {
|
||||||
|
targets := make([]DevTarget, 0, len(h.devTargets))
|
||||||
|
for _, dt := range h.devTargets {
|
||||||
|
targets = append(targets, *dt)
|
||||||
|
}
|
||||||
|
sort.Slice(targets, func(i, j int) bool { return targets[i].ID < targets[j].ID })
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDevProxy(target *DevTarget) (*url.URL, *httputil.ReverseProxy, error) {
|
||||||
|
u, err := url.Parse(target.Target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix IPv6: resolve "localhost" to 127.0.0.1 to avoid connection refused on systems
|
||||||
|
// where localhost resolves to [::1] but the dev server only listens on IPv4.
|
||||||
|
if u.Hostname() == "localhost" {
|
||||||
|
u.Host = net.JoinHostPort("127.0.0.1", u.Port())
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(u)
|
||||||
|
proxy.ModifyResponse = func(resp *http.Response) error {
|
||||||
|
// Prevent browser/WebView from caching dev proxy responses (CSS, JS, etc.)
|
||||||
|
resp.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
resp.Header.Del("ETag")
|
||||||
|
resp.Header.Del("Last-Modified")
|
||||||
|
|
||||||
|
ct := resp.Header.Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "text/html") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
modified := injectDevProxyScript(body)
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(modified))
|
||||||
|
resp.ContentLength = int64(len(modified))
|
||||||
|
resp.Header.Set("Content-Length", strconv.Itoa(len(modified)))
|
||||||
|
resp.Header.Del("Content-Encoding")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||||
|
<html><head><style>
|
||||||
|
body{background:#1c1c1e;color:#fff;font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||||
|
.box{text-align:center;padding:32px}
|
||||||
|
h2{margin:0 0 12px;font-size:20px;font-weight:600}
|
||||||
|
p{color:#8e8e93;font-size:14px;margin:0}
|
||||||
|
</style></head><body><div class="box"><h2>Cannot connect</h2><p>%s</p><p style="margin-top:8px;font-size:12px">Target: %s</p></div></body></html>`,
|
||||||
|
escapeHTMLString(err.Error()), escapeHTMLString(target.Target))
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, proxy, nil
|
||||||
|
}
|
||||||
27
pkg/miniapp/http_helpers.go
Normal file
27
pkg/miniapp/http_helpers.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
package miniapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSONError(w http.ResponseWriter, status int, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMethodNotAllowed(w http.ResponseWriter) {
|
||||||
|
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSONBody(r *http.Request, limit int64, dst any) error {
|
||||||
|
dec := json.NewDecoder(io.LimitReader(r.Body, limit))
|
||||||
|
return dec.Decode(dst)
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@ import (
|
||||||
// apiLogsSnapshot creates a tar.gz snapshot of the current log buffer.
|
// apiLogsSnapshot creates a tar.gz snapshot of the current log buffer.
|
||||||
func (h *Handler) apiLogsSnapshot(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiLogsSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
writeMethodNotAllowed(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ func (h *Handler) apiLogsSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
snapshotDir := filepath.Join(h.workspace, "logs", "snapshots")
|
snapshotDir := filepath.Join(h.workspace, "logs", "snapshots")
|
||||||
if err := os.MkdirAll(snapshotDir, 0o755); err != nil {
|
if err := os.MkdirAll(snapshotDir, 0o755); err != nil {
|
||||||
http.Error(w, `{"error":"cannot create snapshot dir"}`, http.StatusInternalServerError)
|
writeJSONError(w, http.StatusInternalServerError, "cannot create snapshot dir")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,7 +36,7 @@ func (h *Handler) apiLogsSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||||
// Create tar.gz
|
// Create tar.gz
|
||||||
f, err := os.Create(snapshotPath)
|
f, err := os.Create(snapshotPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"cannot create snapshot file"}`, http.StatusInternalServerError)
|
writeJSONError(w, http.StatusInternalServerError, "cannot create snapshot file")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,7 +88,7 @@ func (h *Handler) apiLogsSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||||
// apiLogsSnapshotDownload serves a snapshot tar.gz file.
|
// apiLogsSnapshotDownload serves a snapshot tar.gz file.
|
||||||
func (h *Handler) apiLogsSnapshotDownload(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiLogsSnapshotDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
writeMethodNotAllowed(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,7 +96,7 @@ func (h *Handler) apiLogsSnapshotDownload(w http.ResponseWriter, r *http.Request
|
||||||
id = filepath.Base(id) // path traversal prevention
|
id = filepath.Base(id) // path traversal prevention
|
||||||
|
|
||||||
if id == "" || id == "." || id == ".." {
|
if id == "" || id == "." || id == ".." {
|
||||||
http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
|
writeJSONError(w, http.StatusBadRequest, "invalid id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,7 +104,7 @@ func (h *Handler) apiLogsSnapshotDownload(w http.ResponseWriter, r *http.Request
|
||||||
snapshotPath := filepath.Join(h.workspace, "logs", "snapshots", filename)
|
snapshotPath := filepath.Join(h.workspace, "logs", "snapshots", filename)
|
||||||
|
|
||||||
if _, err := os.Stat(snapshotPath); os.IsNotExist(err) {
|
if _, err := os.Stat(snapshotPath); os.IsNotExist(err) {
|
||||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
writeJSONError(w, http.StatusNotFound, "not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,33 +84,37 @@ func (h *Handler) SetOrchBroadcaster(b *orch.Broadcaster) {
|
||||||
h.orchBroadcaster = b
|
h.orchBroadcaster = b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleProtectedFunc(mux *http.ServeMux, pattern string, handler http.HandlerFunc) {
|
||||||
|
mux.HandleFunc(pattern, h.requireAuth(handler))
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterRoutes registers Mini App routes on the given mux.
|
// RegisterRoutes registers Mini App routes on the given mux.
|
||||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/miniapp", h.serveIndex)
|
mux.HandleFunc("/miniapp", h.serveIndex)
|
||||||
mux.HandleFunc("/miniapp/index.html", h.serveIndex)
|
mux.HandleFunc("/miniapp/index.html", h.serveIndex)
|
||||||
mux.HandleFunc("/miniapp/", h.serveStatic)
|
mux.HandleFunc("/miniapp/", h.serveStatic)
|
||||||
mux.HandleFunc("/miniapp/api/skills", h.requireAuth(h.apiSkills))
|
h.handleProtectedFunc(mux, "/miniapp/api/skills", h.apiSkills)
|
||||||
mux.HandleFunc("/miniapp/api/plan", h.requireAuth(h.apiPlan))
|
h.handleProtectedFunc(mux, "/miniapp/api/plan", h.apiPlan)
|
||||||
mux.HandleFunc("/miniapp/api/session", h.requireAuth(h.apiSession))
|
h.handleProtectedFunc(mux, "/miniapp/api/session", h.apiSession)
|
||||||
mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions))
|
h.handleProtectedFunc(mux, "/miniapp/api/sessions", h.apiSessions)
|
||||||
mux.HandleFunc("/miniapp/api/sessions/graph", h.requireAuth(h.apiSessionGraph))
|
h.handleProtectedFunc(mux, "/miniapp/api/sessions/graph", h.apiSessionGraph)
|
||||||
mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand))
|
h.handleProtectedFunc(mux, "/miniapp/api/command", h.apiCommand)
|
||||||
mux.HandleFunc("/miniapp/api/context", h.requireAuth(h.apiContext))
|
h.handleProtectedFunc(mux, "/miniapp/api/context", h.apiContext)
|
||||||
mux.HandleFunc("/miniapp/api/prompt", h.requireAuth(h.apiPrompt))
|
h.handleProtectedFunc(mux, "/miniapp/api/prompt", h.apiPrompt)
|
||||||
mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit))
|
h.handleProtectedFunc(mux, "/miniapp/api/git", h.apiGit)
|
||||||
mux.HandleFunc("/miniapp/api/worktrees", h.requireAuth(h.apiWorktrees))
|
h.handleProtectedFunc(mux, "/miniapp/api/worktrees", h.apiWorktrees)
|
||||||
mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev))
|
h.handleProtectedFunc(mux, "/miniapp/api/dev", h.apiDev)
|
||||||
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents))
|
h.handleProtectedFunc(mux, "/miniapp/api/events", h.apiEvents)
|
||||||
mux.HandleFunc("/miniapp/api/logs/ws", h.requireAuth(h.wsLogs))
|
h.handleProtectedFunc(mux, "/miniapp/api/logs/ws", h.wsLogs)
|
||||||
mux.HandleFunc("/miniapp/api/logs/snapshot", h.requireAuth(h.apiLogsSnapshot))
|
h.handleProtectedFunc(mux, "/miniapp/api/logs/snapshot", h.apiLogsSnapshot)
|
||||||
mux.HandleFunc("/miniapp/api/logs/snapshot/", h.requireAuth(h.apiLogsSnapshotDownload))
|
h.handleProtectedFunc(mux, "/miniapp/api/logs/snapshot/", h.apiLogsSnapshotDownload)
|
||||||
mux.HandleFunc("/miniapp/api/orchestration/ws", h.requireAuth(h.wsOrchestration))
|
h.handleProtectedFunc(mux, "/miniapp/api/orchestration/ws", h.wsOrchestration)
|
||||||
mux.HandleFunc("/miniapp/dev/console", h.apiDevConsole)
|
mux.HandleFunc("/miniapp/dev/console", h.apiDevConsole)
|
||||||
mux.HandleFunc("/miniapp/dev/", h.serveDevProxy)
|
mux.HandleFunc("/miniapp/dev/", h.serveDevProxy)
|
||||||
mux.HandleFunc("/miniapp/api/cache", h.requireAuth(h.apiCache))
|
h.handleProtectedFunc(mux, "/miniapp/api/cache", h.apiCache)
|
||||||
mux.HandleFunc("/miniapp/api/research", h.requireAuth(h.apiResearch))
|
h.handleProtectedFunc(mux, "/miniapp/api/research", h.apiResearch)
|
||||||
mux.HandleFunc("/miniapp/api/research/focus", h.requireAuth(h.apiResearchFocus))
|
h.handleProtectedFunc(mux, "/miniapp/api/research/focus", h.apiResearchFocus)
|
||||||
mux.HandleFunc("/miniapp/api/research/", h.requireAuth(h.apiResearchDetail))
|
h.handleProtectedFunc(mux, "/miniapp/api/research/", h.apiResearchDetail)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ func (h *Handler) wsLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
// {"type":"event","event":{...orch.Event}} -- pushed on each state change
|
// {"type":"event","event":{...orch.Event}} -- pushed on each state change
|
||||||
func (h *Handler) wsOrchestration(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) wsOrchestration(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.orchBroadcaster == nil {
|
if h.orchBroadcaster == nil {
|
||||||
http.Error(w, `{"error":"orchestration not enabled"}`, http.StatusServiceUnavailable)
|
writeJSONError(w, http.StatusServiceUnavailable, "orchestration not enabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,11 +76,7 @@ func (b *Broadcaster) Unsubscribe(sub *Subscriber) {
|
||||||
func (b *Broadcaster) Snapshot() []AgentInfo {
|
func (b *Broadcaster) Snapshot() []AgentInfo {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
out := make([]AgentInfo, 0, len(b.agents))
|
return b.snapshotLocked()
|
||||||
for _, a := range b.agents {
|
|
||||||
out = append(out, *a)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportSpawn implements AgentReporter.
|
// ReportSpawn implements AgentReporter.
|
||||||
|
|
@ -110,6 +106,35 @@ func (b *Broadcaster) Publish(ev Event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
|
b.applyEventLocked(ev)
|
||||||
|
subs := b.subscribersLocked()
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
for _, sub := range subs {
|
||||||
|
select {
|
||||||
|
case sub.Ch <- ev:
|
||||||
|
default: // subscriber slow — drop (non-blocking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broadcaster) snapshotLocked() []AgentInfo {
|
||||||
|
out := make([]AgentInfo, 0, len(b.agents))
|
||||||
|
for _, a := range b.agents {
|
||||||
|
out = append(out, *a)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broadcaster) subscribersLocked() []*Subscriber {
|
||||||
|
subs := make([]*Subscriber, 0, len(b.subs))
|
||||||
|
for sub := range b.subs {
|
||||||
|
subs = append(subs, sub)
|
||||||
|
}
|
||||||
|
return subs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broadcaster) applyEventLocked(ev Event) {
|
||||||
switch ev.Type {
|
switch ev.Type {
|
||||||
case "agent_spawn":
|
case "agent_spawn":
|
||||||
b.agents[ev.ID] = &AgentInfo{
|
b.agents[ev.ID] = &AgentInfo{
|
||||||
|
|
@ -127,17 +152,4 @@ func (b *Broadcaster) Publish(ev Event) {
|
||||||
case "agent_gc":
|
case "agent_gc":
|
||||||
delete(b.agents, ev.ID)
|
delete(b.agents, ev.ID)
|
||||||
}
|
}
|
||||||
// snapshot subs while holding lock, then release before sending
|
|
||||||
subs := make([]*Subscriber, 0, len(b.subs))
|
|
||||||
for sub := range b.subs {
|
|
||||||
subs = append(subs, sub)
|
|
||||||
}
|
|
||||||
b.mu.Unlock()
|
|
||||||
|
|
||||||
for _, sub := range subs {
|
|
||||||
select {
|
|
||||||
case sub.Ch <- ev:
|
|
||||||
default: // subscriber slow — drop (non-blocking)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@ type ResearchStore struct {
|
||||||
workspace string
|
workspace string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nowRFC3339() string {
|
||||||
|
return time.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
// OpenResearchStore opens (or creates) the research SQLite database.
|
// OpenResearchStore opens (or creates) the research SQLite database.
|
||||||
func OpenResearchStore(dbPath, workspace string) (*ResearchStore, error) {
|
func OpenResearchStore(dbPath, workspace string) (*ResearchStore, error) {
|
||||||
connStr := "file:" + dbPath + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000"
|
connStr := "file:" + dbPath + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000"
|
||||||
|
|
@ -254,7 +258,7 @@ func (s *ResearchStore) SetTaskStatus(id string, status TaskStatus) error {
|
||||||
return fmt.Errorf("invalid transition: %s → %s", task.Status, status)
|
return fmt.Errorf("invalid transition: %s → %s", task.Status, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := nowRFC3339()
|
||||||
completedAt := ""
|
completedAt := ""
|
||||||
if status == StatusCompleted || status == StatusFailed {
|
if status == StatusCompleted || status == StatusFailed {
|
||||||
completedAt = now
|
completedAt = now
|
||||||
|
|
@ -267,7 +271,7 @@ func (s *ResearchStore) SetTaskStatus(id string, status TaskStatus) error {
|
||||||
|
|
||||||
// UpdateTask updates a task's title and/or description.
|
// UpdateTask updates a task's title and/or description.
|
||||||
func (s *ResearchStore) UpdateTask(id, title, description string) error {
|
func (s *ResearchStore) UpdateTask(id, title, description string) error {
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := nowRFC3339()
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`UPDATE research_tasks SET title = ?, description = ?, updated_at = ? WHERE id = ?`,
|
`UPDATE research_tasks SET title = ?, description = ?, updated_at = ? WHERE id = ?`,
|
||||||
title, description, now, id)
|
title, description, now, id)
|
||||||
|
|
@ -373,7 +377,7 @@ func (s *ResearchStore) SearchTasks(query string) ([]*Task, error) {
|
||||||
|
|
||||||
// TouchLastResearched updates the last_researched_at timestamp for a task.
|
// TouchLastResearched updates the last_researched_at timestamp for a task.
|
||||||
func (s *ResearchStore) TouchLastResearched(taskID string) error {
|
func (s *ResearchStore) TouchLastResearched(taskID string) error {
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := nowRFC3339()
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`UPDATE research_tasks SET last_researched_at = ?, updated_at = ? WHERE id = ?`,
|
`UPDATE research_tasks SET last_researched_at = ?, updated_at = ? WHERE id = ?`,
|
||||||
now, now, taskID)
|
now, now, taskID)
|
||||||
|
|
@ -385,7 +389,7 @@ func (s *ResearchStore) SetInterval(taskID, interval string) error {
|
||||||
if _, err := ParseInterval(interval); err != nil {
|
if _, err := ParseInterval(interval); err != nil {
|
||||||
return fmt.Errorf("invalid interval %q: %w", interval, err)
|
return fmt.Errorf("invalid interval %q: %w", interval, err)
|
||||||
}
|
}
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := nowRFC3339()
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`UPDATE research_tasks SET interval = ?, updated_at = ? WHERE id = ?`,
|
`UPDATE research_tasks SET interval = ?, updated_at = ? WHERE id = ?`,
|
||||||
interval, now, taskID)
|
interval, now, taskID)
|
||||||
|
|
@ -395,24 +399,7 @@ func (s *ResearchStore) SetInterval(taskID, interval string) error {
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
func scanTask(row *sql.Row) (*Task, error) {
|
func scanTask(row *sql.Row) (*Task, error) {
|
||||||
var t Task
|
return scanTaskRow(row)
|
||||||
var statusStr, lastResearchedStr, createdStr, updatedStr, completedStr string
|
|
||||||
err := row.Scan(&t.ID, &t.Title, &t.Slug, &t.Description, &statusStr,
|
|
||||||
&t.OutputDir, &t.Interval, &lastResearchedStr, &createdStr, &updatedStr, &completedStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
t.Status = TaskStatus(statusStr)
|
|
||||||
if t.Interval == "" {
|
|
||||||
t.Interval = DefaultResearchInterval
|
|
||||||
}
|
|
||||||
t.LastResearchedAt, _ = time.Parse(time.RFC3339, lastResearchedStr)
|
|
||||||
t.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
|
||||||
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
|
||||||
if completedStr != "" {
|
|
||||||
t.CompletedAt, _ = time.Parse(time.RFC3339, completedStr)
|
|
||||||
}
|
|
||||||
return &t, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type rowScanner interface {
|
type rowScanner interface {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue