mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-12 12:18:55 +02:00
* feat(server): add quick-create issue async task path Adds POST /api/issues/quick-create which validates the picked agent's reachability up front (not archived, has runtime, runtime online) then queues an issue-less agent task whose context JSONB carries the user's natural-language prompt + requester + workspace. Daemon claim resolves the workspace from the context, and the prompt builder switches to a quick-create template instructing the agent to translate the prompt into a single multica issue create call. Task completion writes a success inbox item to the requester pointing at the newly-created issue (located by querying the agent's most recent issue in the workspace since task start, so we don't depend on agent stdout shape). Failures write an action_required inbox item carrying the original prompt + agent id so the frontend can offer "Edit as advanced form" without losing input. * feat(views): quick-create issue modal + inbox failure CTA Adds a streamlined create-issue UI bound to the c shortcut: pick an agent, type one line, submit. The modal closes immediately and the agent translates the prompt into a multica issue create call in the background. Shift+c keeps the legacy advanced form for users who want every field. The "Advanced" button inside the new modal seeds the shared issue-draft store with the prompt + picked agent so switching mid-flow doesn't lose input. Last-used agent persists per (user, workspace) via a workspace-aware zustand store so frequent users skip the picker on every open. Inbox renders quick_create_done items with a status pin to the new issue and quick_create_failed items with an "Edit as advanced form" CTA that re-seeds the legacy modal with the original prompt. ApiError now carries the parsed JSON body so the modal can branch on the structured agent_unavailable code without parsing the error message. * fix(quick-create): execenv injection, claim race, private-agent permission Addresses GPT-Boy review on #1786: 1. execenv was rendering the assignment-task issue_context.md / runtime workflow even for quick-create, telling the agent to call `multica issue get/status/comment add` against an empty IssueID. Adds QuickCreatePrompt to TaskContextForEnv, plus a quick-create branch in renderIssueContext + the runtime_config workflow that instructs the agent to run a single `multica issue create` and exit, with explicit "do NOT call issue get/status/comment add" guards. 2. ClaimAgentTask serialized only on issue_id / chat_session_id, so concurrent quick-creates on the same agent (both NULL on those columns) ran in parallel — making the success-inbox lookup race over "most recent issue by this agent". Adds a third OR clause that treats "all four FKs NULL" as a serialization key for the same agent, so quick-create tasks on a given agent run one at a time. 3. QuickCreateIssue handler bypassed the private-agent ownership rule that validateAssigneePair enforces elsewhere — a user could POST a private agent_id they didn't own and trigger it. Now routes the picked agent through validateAssigneePair before the runtime liveness check. 4. Clarifies the quick-create-store namespacing comment to match the actual workspace-aware StateStorage convention used by the other issue stores (per-user is browser-profile-local). * fix(quick-create): branch Output section + deterministic origin lookup Addresses GPT-Boy's second-pass review on #1786: 1. The runtime_config.go Output section forced "Final results MUST be delivered via multica issue comment add" for every non-autopilot task — quick-create still got this conflicting instruction even though there's no issue to comment on. Switched the Output block to a three-way switch so quick-create gets a tailored "stdout is captured automatically; do NOT call comment add" branch matching the autopilot variant. 2. Completion lookup was "most recent issue created by this agent since task.started_at", which races against concurrent issue creates by the same agent (assignment task running alongside quick-create when max_concurrent_tasks > 1). Replaced with a deterministic origin link: - Migration 060 extends issue.origin_type CHECK to allow 'quick_create'. - Daemon sets MULTICA_QUICK_CREATE_TASK_ID env var when running a quick-create task. - multica issue create CLI reads the env var and stamps the new issue with origin_type=quick_create + origin_id=<task_id>. - Server CreateIssue handler accepts (origin_type, origin_id) from trusted callers (only "quick_create" is allowed; the pair is rejected unless both fields are provided together). - notifyQuickCreateCompleted now calls GetIssueByOrigin keyed on (workspace_id, "quick_create", task.ID) — no more time-window racing against parallel agent activity. The old GetRecentIssueByCreatorSince query is removed.
243 lines
8.8 KiB
Go
243 lines
8.8 KiB
Go
package execenv
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// writeContextFiles renders and writes .agent_context/issue_context.md and
|
|
// skills into the appropriate provider-native location.
|
|
//
|
|
// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery)
|
|
// Codex: skills → handled separately in Prepare via codex-home
|
|
// Copilot: skills → {workDir}/.github/skills/{name}/SKILL.md (native project-level discovery)
|
|
// OpenCode: skills → {workDir}/.config/opencode/skills/{name}/SKILL.md (native discovery)
|
|
// Pi: skills → {workDir}/.pi/skills/{name}/SKILL.md (native discovery)
|
|
// Cursor: skills → {workDir}/.cursor/skills/{name}/SKILL.md (native discovery)
|
|
// Kimi: skills → {workDir}/.kimi/skills/{name}/SKILL.md (native discovery)
|
|
// Kiro: skills → {workDir}/.kiro/skills/{name}/SKILL.md (native discovery)
|
|
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
|
|
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
|
|
contextDir := filepath.Join(workDir, ".agent_context")
|
|
if err := os.MkdirAll(contextDir, 0o755); err != nil {
|
|
return fmt.Errorf("create .agent_context dir: %w", err)
|
|
}
|
|
|
|
content := renderIssueContext(provider, ctx)
|
|
path := filepath.Join(contextDir, "issue_context.md")
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
return fmt.Errorf("write issue_context.md: %w", err)
|
|
}
|
|
|
|
if len(ctx.AgentSkills) > 0 {
|
|
skillsDir, err := resolveSkillsDir(workDir, provider)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve skills dir: %w", err)
|
|
}
|
|
// Codex skills are written to codex-home in Prepare; skip here.
|
|
if provider != "codex" {
|
|
if err := writeSkillFiles(skillsDir, ctx.AgentSkills); err != nil {
|
|
return fmt.Errorf("write skill files: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// resolveSkillsDir returns the directory where skills should be written
|
|
// based on the agent provider.
|
|
func resolveSkillsDir(workDir, provider string) (string, error) {
|
|
var skillsDir string
|
|
switch provider {
|
|
case "claude":
|
|
// Claude Code natively discovers skills from .claude/skills/ in the workdir.
|
|
skillsDir = filepath.Join(workDir, ".claude", "skills")
|
|
case "copilot":
|
|
// GitHub Copilot CLI natively discovers project-level skills from
|
|
// .github/skills/<name>/SKILL.md (takes precedence over user-level
|
|
// skills in ~/.copilot/skills/).
|
|
// See: https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference
|
|
skillsDir = filepath.Join(workDir, ".github", "skills")
|
|
case "opencode":
|
|
// OpenCode natively discovers skills from .config/opencode/skills/ in the workdir.
|
|
skillsDir = filepath.Join(workDir, ".config", "opencode", "skills")
|
|
case "pi":
|
|
// Pi natively discovers skills from .pi/skills/ in the workdir.
|
|
skillsDir = filepath.Join(workDir, ".pi", "skills")
|
|
case "cursor":
|
|
// Cursor natively discovers skills from .cursor/skills/ in the workdir.
|
|
skillsDir = filepath.Join(workDir, ".cursor", "skills")
|
|
case "kimi":
|
|
// Kimi Code CLI auto-discovers project-level skills from .kimi/skills/
|
|
// in the workdir. See https://moonshotai.github.io/kimi-cli/en/customization/skills.html
|
|
skillsDir = filepath.Join(workDir, ".kimi", "skills")
|
|
case "kiro":
|
|
// Kiro CLI auto-discovers project-level skills from .kiro/skills/
|
|
// in the workdir.
|
|
skillsDir = filepath.Join(workDir, ".kiro", "skills")
|
|
default:
|
|
// Fallback: write to .agent_context/skills/ (referenced by meta config).
|
|
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
|
|
}
|
|
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
return skillsDir, nil
|
|
}
|
|
|
|
var nonAlphaNum = regexp.MustCompile(`[^a-z0-9]+`)
|
|
|
|
// sanitizeSkillName converts a skill name to a safe directory name.
|
|
func sanitizeSkillName(name string) string {
|
|
s := strings.ToLower(strings.TrimSpace(name))
|
|
s = nonAlphaNum.ReplaceAllString(s, "-")
|
|
s = strings.Trim(s, "-")
|
|
if s == "" {
|
|
s = "skill"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// writeSkillFiles writes skill directories into the given parent directory.
|
|
// Each skill gets its own subdirectory containing SKILL.md and supporting files.
|
|
func writeSkillFiles(skillsDir string, skills []SkillContextForEnv) error {
|
|
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
|
return fmt.Errorf("create skills dir: %w", err)
|
|
}
|
|
|
|
for _, skill := range skills {
|
|
dir := filepath.Join(skillsDir, sanitizeSkillName(skill.Name))
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write main SKILL.md
|
|
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(skill.Content), 0o644); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write supporting files
|
|
for _, f := range skill.Files {
|
|
fpath := filepath.Join(dir, f.Path)
|
|
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(fpath, []byte(f.Content), 0o644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// renderIssueContext builds the markdown content for issue_context.md.
|
|
func renderIssueContext(provider string, ctx TaskContextForEnv) string {
|
|
if ctx.AutopilotRunID != "" {
|
|
return renderAutopilotContext(ctx)
|
|
}
|
|
if ctx.QuickCreatePrompt != "" {
|
|
return renderQuickCreateContext(ctx)
|
|
}
|
|
|
|
var b strings.Builder
|
|
|
|
b.WriteString("# Task Assignment\n\n")
|
|
fmt.Fprintf(&b, "**Issue ID:** %s\n\n", ctx.IssueID)
|
|
|
|
if ctx.TriggerCommentID != "" {
|
|
b.WriteString("**Trigger:** Comment Reply\n")
|
|
b.WriteString("**Triggering comment ID:** `" + ctx.TriggerCommentID + "`\n\n")
|
|
} else {
|
|
b.WriteString("**Trigger:** New Assignment\n\n")
|
|
}
|
|
|
|
b.WriteString("## Quick Start\n\n")
|
|
fmt.Fprintf(&b, "Run `multica issue get %s --output json` to fetch the full issue details.\n\n", ctx.IssueID)
|
|
|
|
if len(ctx.AgentSkills) > 0 {
|
|
b.WriteString("## Agent Skills\n\n")
|
|
b.WriteString("The following skills are available to you:\n\n")
|
|
for _, skill := range ctx.AgentSkills {
|
|
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// renderQuickCreateContext renders issue_context.md for quick-create tasks.
|
|
// There is no issue yet, so we explicitly tell the agent NOT to call
|
|
// `multica issue get` / `status` / `comment add` — those would either error
|
|
// (empty IssueID) or silently target an unrelated issue.
|
|
func renderQuickCreateContext(ctx TaskContextForEnv) string {
|
|
var b strings.Builder
|
|
b.WriteString("# Quick Create\n\n")
|
|
b.WriteString("**Trigger:** Quick-create modal\n\n")
|
|
b.WriteString("There is NO existing Multica issue for this run. Translate the user input below into a single `multica issue create` invocation, then exit.\n\n")
|
|
b.WriteString("## User input\n\n")
|
|
b.WriteString("> ")
|
|
b.WriteString(ctx.QuickCreatePrompt)
|
|
b.WriteString("\n\n")
|
|
b.WriteString("## Rules\n\n")
|
|
b.WriteString("- Run exactly one `multica issue create` invocation. No retries.\n")
|
|
b.WriteString("- After it succeeds, print `Created MUL-<n>: <title>` and exit.\n")
|
|
b.WriteString("- Do NOT run `multica issue get`, `multica issue status`, or `multica issue comment add` — there is nothing to query, transition, or comment on.\n")
|
|
b.WriteString("- The platform writes the user's success/failure inbox notification automatically based on the CLI exit status.\n\n")
|
|
if len(ctx.AgentSkills) > 0 {
|
|
b.WriteString("## Agent Skills\n\n")
|
|
b.WriteString("The following skills are available, but for quick-create they are usually unnecessary:\n\n")
|
|
for _, skill := range ctx.AgentSkills {
|
|
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func renderAutopilotContext(ctx TaskContextForEnv) string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString("# Autopilot Run\n\n")
|
|
fmt.Fprintf(&b, "**Autopilot run ID:** %s\n\n", ctx.AutopilotRunID)
|
|
if ctx.AutopilotID != "" {
|
|
fmt.Fprintf(&b, "**Autopilot ID:** %s\n\n", ctx.AutopilotID)
|
|
}
|
|
if ctx.AutopilotTitle != "" {
|
|
fmt.Fprintf(&b, "**Title:** %s\n\n", ctx.AutopilotTitle)
|
|
}
|
|
if ctx.AutopilotSource != "" {
|
|
fmt.Fprintf(&b, "**Trigger source:** %s\n\n", ctx.AutopilotSource)
|
|
}
|
|
if ctx.AutopilotTriggerPayload != "" {
|
|
fmt.Fprintf(&b, "## Trigger Payload\n\n```json\n%s\n```\n\n", ctx.AutopilotTriggerPayload)
|
|
}
|
|
|
|
b.WriteString("## Quick Start\n\n")
|
|
b.WriteString("This is a run-only autopilot task with no assigned issue. Do not run `multica issue get` unless the autopilot instructions explicitly ask you to create or update an issue.\n\n")
|
|
if ctx.AutopilotID != "" {
|
|
fmt.Fprintf(&b, "Run `multica autopilot get %s --output json` if you need the full autopilot configuration.\n\n", ctx.AutopilotID)
|
|
}
|
|
if strings.TrimSpace(ctx.AutopilotDescription) != "" {
|
|
b.WriteString("## Autopilot Instructions\n\n")
|
|
b.WriteString(ctx.AutopilotDescription)
|
|
b.WriteString("\n\n")
|
|
}
|
|
|
|
if len(ctx.AgentSkills) > 0 {
|
|
b.WriteString("## Agent Skills\n\n")
|
|
b.WriteString("The following skills are available to you:\n\n")
|
|
for _, skill := range ctx.AgentSkills {
|
|
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
return b.String()
|
|
}
|