chore(agent): remove old client sub-task API (phase A of legacy teardown)
The template-agent model replaces the old sub/swarm task orchestration. Removed
the entire client-facing task surface:
- routes: registerHeicodeTaskRoutes (all /heicode/{sub-agile,swarm}/tasks/*) and
GET /heicode/deployment-targets.
- controllers deleted: heicode_task_create, heicode_client_routes,
heicode_project_artifacts, heicode_artifact_edits, heicode_cloud_deploy
(+ the agent_deliverable_secret_test that covered the deleted markdown-project
parsing).
Build + controller/router/model tests green. Shared backend (AgentDeployment,
agent_runtime_client helpers, admin /api/agent/*) intentionally kept — trimmed in
the next phase.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,167 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// P2: deliverable judgment must read structured artifact_type / file signals,
|
||||
// not just the legacy title/summary/uri string heuristics.
|
||||
func TestRuntimeArtifactsAreSummaryOnly_StructuredFields(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
artifacts []gin.H
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty list is not summary-only",
|
||||
artifacts: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// The regression the review flagged: Runtime now emits
|
||||
// artifact_type=document under runtime://.../artifacts/<task>,
|
||||
// which the old "/artifacts/summary" uri heuristic missed.
|
||||
name: "document type with new uri scheme and no files is summary-only",
|
||||
artifacts: []gin.H{{
|
||||
"artifact_id": "art_1",
|
||||
"artifact_type": "document",
|
||||
"title": "Backend delivery",
|
||||
"summary": "下面是方案总结",
|
||||
"uri": "runtime://swm_x/artifacts/backend_1",
|
||||
}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "code_patch is a real deliverable",
|
||||
artifacts: []gin.H{{
|
||||
"artifact_type": "code_patch",
|
||||
"uri": "runtime://swm_x/artifacts/backend_1",
|
||||
}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "deployment_manifest is a real deliverable",
|
||||
artifacts: []gin.H{{"artifact_type": "deployment_manifest"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "document type but with file-change signal is a deliverable",
|
||||
artifacts: []gin.H{{
|
||||
"artifact_type": "document",
|
||||
"metadata": map[string]any{"files_modified": float64(2)},
|
||||
}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "mixed set with one real deliverable is not summary-only",
|
||||
artifacts: []gin.H{
|
||||
{"artifact_type": "document"},
|
||||
{"artifact_type": "code_patch"},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "legacy runtime execution summary marker still detected",
|
||||
artifacts: []gin.H{{
|
||||
"artifact_type": "other",
|
||||
"title": "Runtime execution summary",
|
||||
}},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, runtimeArtifactsAreSummaryOnly(tc.artifacts))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// P6a: Resource CRUD must reject a secret_ref that is not an azkv:// reference.
|
||||
func TestNormalizeResourcePayload_SecretRefMustBeAzkv(t *testing.T) {
|
||||
base := func(secretRef string) resourcePayload {
|
||||
return resourcePayload{Name: "repo", ResourceType: "git", SecretRef: secretRef}
|
||||
}
|
||||
|
||||
_, err := normalizeResourcePayload(base("https://example.com/token"))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "azkv://")
|
||||
|
||||
_, err = normalizeResourcePayload(base("sk-live-plaintext-leak"))
|
||||
require.Error(t, err)
|
||||
|
||||
got, err := normalizeResourcePayload(base("azkv://heicode-kv.vault.azure.net/secrets/repo"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "azkv://heicode-kv.vault.azure.net/secrets/repo", got.SecretRef)
|
||||
|
||||
// Empty secret_ref stays allowed (secret can be set later via UpsertResourceSecret).
|
||||
_, err = normalizeResourcePayload(base(""))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// P6b: plaintext-secret detection must scan string VALUES, not only key names.
|
||||
func TestValueLooksLikeSecretAndPlaintextScan(t *testing.T) {
|
||||
positives := []string{
|
||||
"sk-abcdefghij1234567890",
|
||||
"sk-live-abcdefghijklmnopqrst",
|
||||
"ghp_abcdefghijklmnopqrstuvwxyz0123",
|
||||
"AKIAIOSFODNN7EXAMPLE",
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.s5h6Qk0c5Qx2hQ",
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END",
|
||||
}
|
||||
for _, s := range positives {
|
||||
require.Truef(t, valueLooksLikeSecret(s), "expected secret-like: %q", s)
|
||||
}
|
||||
|
||||
negatives := []string{
|
||||
"",
|
||||
"main",
|
||||
"https://github.com/org/repo.git",
|
||||
"azkv://heicode-kv.vault.azure.net/secrets/repo",
|
||||
"a normal sentence with sk in it",
|
||||
}
|
||||
for _, s := range negatives {
|
||||
require.Falsef(t, valueLooksLikeSecret(s), "expected NOT secret-like: %q", s)
|
||||
}
|
||||
|
||||
// Value hidden under an innocuous key must now be caught.
|
||||
require.True(t, containsPlaintextSecret(map[string]any{"note": "sk-abcdefghij1234567890"}))
|
||||
require.False(t, containsPlaintextSecret(map[string]any{"repo_url": "https://github.com/o/r.git"}))
|
||||
}
|
||||
|
||||
// P5: a single source of truth for the default model; no placeholder fallback.
|
||||
func TestDefaultAgentModelID_SingleSourceNoPlaceholder(t *testing.T) {
|
||||
def := defaultAgentModelID()
|
||||
require.Equal(t, "gpt-5.4", def)
|
||||
|
||||
// Draft builder must use the single default, never agent-model-<role>.
|
||||
plan := buildAgentDraftAgentPlan("backend", "", nil)
|
||||
require.Equal(t, def, plan.DefaultModelID)
|
||||
require.NotContains(t, plan.DefaultModelID, "agent-model-")
|
||||
|
||||
// Explicit client model is still honored.
|
||||
plan = buildAgentDraftAgentPlan("backend", "gpt-5.4-mini", nil)
|
||||
require.Equal(t, "gpt-5.4-mini", plan.DefaultModelID)
|
||||
|
||||
// Every role template resolves to the single default, no claude-* hardcoding.
|
||||
for _, tpl := range agentRoleTemplates() {
|
||||
require.Equal(t, def, tpl.DefaultModel, "role %s", tpl.Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownProject_SplitsFiles(t *testing.T) {
|
||||
md := "intro text\n\n```python\n# app.py\nprint(1)\n```\n\n## 1) package.json\n```json\n{\"name\":\"x\"}\n```\n\n```text\nno name here\n```"
|
||||
files := parseMarkdownProject(md)
|
||||
require.Len(t, files, 3)
|
||||
require.Equal(t, "app.py", files[0].Path)
|
||||
require.Contains(t, files[0].content, "print(1)")
|
||||
require.Equal(t, "package.json", files[1].Path)
|
||||
require.Equal(t, "file1.txt", files[2].Path)
|
||||
for _, f := range files {
|
||||
require.NotEmpty(t, f.ContentHash)
|
||||
require.Greater(t, f.SizeBytes, 0)
|
||||
require.Equal(t, "file", f.Type)
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// Local-edit revision protocol (unified spec §12.7). Clients upload edits to a
|
||||
// project artifact; Manager stores them as a new accepted revision and is the
|
||||
// owner of the baseline. Runtime reads new revisions via Manager, never the
|
||||
// client's local files directly.
|
||||
|
||||
type localEditChange struct {
|
||||
Op string `json:"op"`
|
||||
Path string `json:"path"`
|
||||
BaseContentHash string `json:"base_content_hash"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type localEditRequest struct {
|
||||
EditID string `json:"edit_id"`
|
||||
BaseArtifactID string `json:"base_artifact_id"`
|
||||
BaseProjectRevision int `json:"base_project_revision"`
|
||||
BaseRevision int `json:"base_revision"`
|
||||
BaseContentHash string `json:"base_content_hash"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
Changes []localEditChange `json:"changes"`
|
||||
}
|
||||
|
||||
func hashString(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// HeicodeArtifactLocalEdit stores a client local edit (single file or batch) as
|
||||
// a new artifact revision, rejecting edits based on a stale baseline.
|
||||
func HeicodeArtifactLocalEdit(c *gin.Context) {
|
||||
record, ok := requireAuthenticatedUserAgentDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
artifactID := strings.TrimSpace(c.Param("artifact_id"))
|
||||
if artifactID == "" {
|
||||
agentError(c, "ARTIFACT_ID_REQUIRED", "artifact_id is required")
|
||||
return
|
||||
}
|
||||
var req localEditRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
agentError(c, "POLICY_REJECTED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
latest, has := model.LatestAgentArtifactRevision(artifactID)
|
||||
currentRev := 1
|
||||
if has {
|
||||
currentRev = latest.Revision
|
||||
}
|
||||
// Conflict: client edited on top of a baseline that is no longer current.
|
||||
if req.BaseRevision > 0 && req.BaseRevision != currentRev {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": gin.H{
|
||||
"code": "ARTIFACT_REVISION_CONFLICT",
|
||||
"message": "本地产物基于旧版本修改,请先同步最新版本后再提交。",
|
||||
"retryable": false,
|
||||
},
|
||||
"data": gin.H{
|
||||
"current_project_revision": currentRev,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
contentHash := strings.TrimSpace(req.ContentHash)
|
||||
if contentHash == "" {
|
||||
if len(req.Changes) > 0 {
|
||||
joined := ""
|
||||
for _, ch := range req.Changes {
|
||||
joined += ch.Path + "\n" + ch.Content + "\n"
|
||||
}
|
||||
contentHash = hashString(joined)
|
||||
} else {
|
||||
contentHash = hashString(req.Content)
|
||||
}
|
||||
}
|
||||
|
||||
payload, _ := common.Marshal(req)
|
||||
newRev := currentRev + 1
|
||||
rev := &model.AgentArtifactRevision{
|
||||
ArtifactID: artifactID,
|
||||
DeploymentID: record.DeploymentID,
|
||||
UserID: record.Plan.UserContext.UserID,
|
||||
Revision: newRev,
|
||||
ProjectRevision: newRev,
|
||||
Source: "client_local_edit",
|
||||
BaseContentHash: strings.TrimSpace(req.BaseContentHash),
|
||||
ContentHash: contentHash,
|
||||
ChangeSummary: strings.TrimSpace(req.ChangeSummary),
|
||||
CreatedBy: "user",
|
||||
// A non-conflicting client edit becomes the current baseline immediately
|
||||
// (Manager owns the baseline). Status transitions: accepted -> applied
|
||||
// once the runtime consumes it on the next /messages or /execute
|
||||
// (unified spec §12.7, gap P1-2).
|
||||
Status: "accepted",
|
||||
PayloadJSON: string(payload),
|
||||
CreatedAtMs: time.Now().UnixMilli(),
|
||||
}
|
||||
if err := model.InsertAgentArtifactRevision(rev); err != nil {
|
||||
common.SysLog("HeicodeArtifactLocalEdit: " + err.Error())
|
||||
agentError(c, "REVISION_PERSIST_FAILED", "failed to store artifact revision")
|
||||
return
|
||||
}
|
||||
notifyRuntimeArtifactEdit(record, rev)
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"artifact_id": artifactID,
|
||||
"revision": newRev,
|
||||
"project_revision": newRev,
|
||||
"source": "client_local_edit",
|
||||
"status": "accepted",
|
||||
"content_hash": contentHash,
|
||||
"created_by": "user",
|
||||
})
|
||||
}
|
||||
|
||||
// HeicodeArtifactRevisions lists the revision history for a project artifact.
|
||||
func HeicodeArtifactRevisions(c *gin.Context) {
|
||||
if _, ok := requireAuthenticatedUserAgentDeployment(c); !ok {
|
||||
return
|
||||
}
|
||||
artifactID := strings.TrimSpace(c.Param("artifact_id"))
|
||||
if artifactID == "" {
|
||||
agentError(c, "ARTIFACT_ID_REQUIRED", "artifact_id is required")
|
||||
return
|
||||
}
|
||||
rows, err := model.ListAgentArtifactRevisions(artifactID)
|
||||
if err != nil {
|
||||
common.SysLog("HeicodeArtifactRevisions: " + err.Error())
|
||||
agentError(c, "REVISION_QUERY_FAILED", "failed to query revisions")
|
||||
return
|
||||
}
|
||||
current := 1
|
||||
if latest, ok := model.LatestAgentArtifactRevision(artifactID); ok {
|
||||
current = latest.Revision
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"artifact_id": artifactID,
|
||||
"current_project_revision": current,
|
||||
"items": rows,
|
||||
"total": len(rows),
|
||||
})
|
||||
}
|
||||
|
||||
// notifyRuntimeArtifactEdit best-effort notifies the runtime that a new local
|
||||
// edit revision is available (unified spec §12.7.4). The runtime reads the
|
||||
// revision via Manager; here we only emit the audit/notification side.
|
||||
func notifyRuntimeArtifactEdit(record agentDeploymentRecord, rev *model.AgentArtifactRevision) {
|
||||
recordAgentAuditEvent(agentEvent{
|
||||
EventID: "evt_" + common.GetUUID()[:12],
|
||||
Event: "artifact.local_edit_received",
|
||||
SchemaVersion: 1,
|
||||
UserID: record.Plan.UserContext.UserID,
|
||||
ChannelID: record.Plan.UserContext.ChannelID,
|
||||
BindingScope: firstPlanBindingScope(record.Plan),
|
||||
DeploymentID: record.DeploymentID,
|
||||
CorrelationID: record.Plan.Metadata.CorrelationID,
|
||||
OccurredAt: agentNow(),
|
||||
}, "manager_artifact_edit", rev.ArtifactID, "", "ok")
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// HeicodeListTaskApprovals lists approval gates for a task (unified spec §15).
|
||||
// Scoped to the authenticated user's deployment, optional ?status= filter
|
||||
// (e.g. pending). The client polls this to render the approval inbox; decisions
|
||||
// go through the existing approve/reject endpoints.
|
||||
func HeicodeListTaskApprovals(c *gin.Context) {
|
||||
record, ok := requireAuthenticatedUserAgentDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := model.DB.Where("deployment_id = ?", record.DeploymentID).
|
||||
Where("user_id = ?", c.GetInt("id"))
|
||||
statusFilter := strings.TrimSpace(c.Query("status"))
|
||||
if statusFilter != "" {
|
||||
q = q.Where("status = ?", statusFilter)
|
||||
}
|
||||
var approvals []model.AgentApprovalRequest
|
||||
if err := q.Order("created_at desc, id desc").Limit(200).Find(&approvals).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
items := make([]agentApprovalResponse, 0, len(approvals))
|
||||
for i := range approvals {
|
||||
expireAgentApprovalIfNeeded(&approvals[i])
|
||||
if statusFilter != "" && approvals[i].Status != statusFilter {
|
||||
continue
|
||||
}
|
||||
items = append(items, agentApprovalToResponse(approvals[i], nil))
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"task_id": record.DeploymentID,
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
})
|
||||
}
|
||||
|
||||
// runtimeNum extracts a numeric metric from a runtime map by trying several key
|
||||
// aliases, tolerating float/int/string encodings.
|
||||
func runtimeNum(m map[string]any, keys ...string) float64 {
|
||||
for _, k := range keys {
|
||||
v, ok := m[k]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case string:
|
||||
if f, err := strconv.ParseFloat(strings.TrimSpace(n), 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// artifactSourceRole reads the originating agent role from an artifact's
|
||||
// metadata, so artifacts can be grouped per agent (gap A). Empty when the
|
||||
// runtime does not tag artifacts with a source role yet.
|
||||
func artifactSourceRole(a model.AgentArtifact) string {
|
||||
if strings.TrimSpace(a.MetadataJSON) == "" {
|
||||
return ""
|
||||
}
|
||||
var meta map[string]any
|
||||
if err := common.UnmarshalJsonStr(a.MetadataJSON, &meta); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, k := range []string{"source_agent_role", "agent_role", "role", "source_role"} {
|
||||
if v, ok := meta[k]; ok {
|
||||
if s := strings.TrimSpace(fmt.Sprint(v)); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HeicodeTaskWorkflow projects a deployment into the client-facing "workflow"
|
||||
// shape consumed by the desktop right-hand task panel (unified spec §7.9 /
|
||||
// §10.2). task_id is the deployment_id. Status is the Manager-judged
|
||||
// display_status, so the client never has to interpret raw runtime state.
|
||||
//
|
||||
// Rich fields (gap A): phases[], per-agent tokens/tools/elapsed_seconds/
|
||||
// artifact_ids and top-level aggregates are filled from the runtime status
|
||||
// diagnostics. Values the sub-mode runtime does not yet report (per-agent
|
||||
// tokens/tools, artifact source role, phase breakdown) surface as 0 / [] until
|
||||
// agent_management emits them.
|
||||
func HeicodeTaskWorkflow(c *gin.Context) {
|
||||
record, ok := requireAuthenticatedUserAgentDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
record = reconcileDeploymentFromRuntime(c.Request.Context(), record)
|
||||
record = withDisplayStatus(record)
|
||||
diag := agentRuntimeDiagnosticsForRecord(c.Request.Context(), record)
|
||||
|
||||
// Index runtime-reported per-agent rows by role for metric enrichment.
|
||||
diagAgentsByRole := map[string]map[string]any{}
|
||||
for _, da := range diag.Agents {
|
||||
role := strings.ToLower(strings.TrimSpace(firstNonEmpty(
|
||||
ginString(da, "role"), ginString(da, "agent_role"), ginString(da, "name"))))
|
||||
if role != "" {
|
||||
diagAgentsByRole[role] = da
|
||||
}
|
||||
}
|
||||
|
||||
artifacts, _ := model.ListAgentArtifacts(model.ListAgentArtifactsFilter{
|
||||
DeploymentID: record.DeploymentID,
|
||||
Limit: 500,
|
||||
})
|
||||
artItems := make([]gin.H, 0, len(artifacts))
|
||||
allArtifactIDs := make([]string, 0, len(artifacts))
|
||||
artifactIDsByRole := map[string][]string{}
|
||||
for _, a := range artifacts {
|
||||
artItems = append(artItems, gin.H{
|
||||
"artifact_id": a.ArtifactID,
|
||||
"title": a.Title,
|
||||
"artifact_type": a.ArtifactType,
|
||||
"summary": a.Summary,
|
||||
})
|
||||
allArtifactIDs = append(allArtifactIDs, a.ArtifactID)
|
||||
if role := strings.ToLower(artifactSourceRole(a)); role != "" {
|
||||
artifactIDsByRole[role] = append(artifactIDsByRole[role], a.ArtifactID)
|
||||
}
|
||||
}
|
||||
|
||||
var totTokens, totTools float64
|
||||
agents := make([]gin.H, 0, len(record.AgentInstances))
|
||||
for _, inst := range record.AgentInstances {
|
||||
role := strings.ToLower(strings.TrimSpace(inst.Role))
|
||||
da := diagAgentsByRole[role]
|
||||
tokens := runtimeNum(da, "tokens", "tokens_used", "total_tokens")
|
||||
tools := runtimeNum(da, "tools", "tool_calls", "tools_used")
|
||||
elapsed := runtimeNum(da, "elapsed_seconds", "duration_seconds")
|
||||
totTokens += tokens
|
||||
totTools += tools
|
||||
ids := artifactIDsByRole[role]
|
||||
if ids == nil {
|
||||
ids = []string{}
|
||||
}
|
||||
agents = append(agents, gin.H{
|
||||
"agent_id": inst.InstanceID,
|
||||
"name": inst.Role,
|
||||
"role": inst.Role,
|
||||
"status": firstNonEmpty(inst.RuntimeState, inst.Phase),
|
||||
"tokens": tokens,
|
||||
"tools": tools,
|
||||
"elapsed_seconds": elapsed,
|
||||
"artifact_ids": ids,
|
||||
})
|
||||
}
|
||||
|
||||
// Deployment-level metrics the sub-mode runtime reports (tokens_used,
|
||||
// elapsed_seconds, total_messages). Per-agent splits override the aggregate
|
||||
// when the runtime provides them.
|
||||
metricsTokens := runtimeNum(diag.Metrics, "tokens_used", "total_tokens", "tokens")
|
||||
if totTokens > 0 {
|
||||
metricsTokens = totTokens
|
||||
}
|
||||
metrics := gin.H{
|
||||
"tokens_used": metricsTokens,
|
||||
"tools": totTools,
|
||||
"elapsed_seconds": runtimeNum(diag.Metrics, "elapsed_seconds", "duration_seconds"),
|
||||
"total_messages": runtimeNum(diag.Metrics, "total_messages"),
|
||||
}
|
||||
|
||||
// phases[]: pass through a runtime-provided phase breakdown if present,
|
||||
// otherwise expose the current phase as a single entry so the field is
|
||||
// always an array (gap A; full breakdown pending runtime support).
|
||||
phases := mapSliceFromAny(diag.Progress)
|
||||
if len(phases) == 0 {
|
||||
roleNames := make([]string, 0, len(record.AgentInstances))
|
||||
for _, inst := range record.AgentInstances {
|
||||
roleNames = append(roleNames, inst.Role)
|
||||
}
|
||||
curPhase := firstNonEmpty(record.Phase, diag.Phase)
|
||||
if curPhase != "" {
|
||||
phases = []gin.H{{
|
||||
"phase_id": curPhase,
|
||||
"name": curPhase,
|
||||
"status": record.DisplayStatus,
|
||||
"agents": roleNames,
|
||||
}}
|
||||
} else {
|
||||
phases = []gin.H{}
|
||||
}
|
||||
}
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"workflow_id": record.DeploymentID,
|
||||
"task_id": record.DeploymentID,
|
||||
"deployment_id": record.DeploymentID,
|
||||
"conversation_id": record.Plan.Metadata.CorrelationID,
|
||||
"mode": heicodeClientMode(record),
|
||||
"sub_mode": firstNonEmpty(record.SubMode, "agile"),
|
||||
"title": firstNonEmpty(record.Plan.Objective, record.DeploymentID),
|
||||
"summary": record.Plan.Objective,
|
||||
"status": record.DisplayStatus,
|
||||
"display_status": record.DisplayStatus,
|
||||
// Three-layer status (unified spec §10.2): Manager judges display_status;
|
||||
// cloud_* is the control-plane status, runtime_* is the runtime's status.
|
||||
"cloud_deployment_status": record.Status,
|
||||
"runtime_execution_status": record.RuntimeState,
|
||||
"last_synced_at": record.UpdatedAt,
|
||||
"phase": record.Phase,
|
||||
"phases": phases,
|
||||
"agent_count": len(record.AgentInstances),
|
||||
"agents": agents,
|
||||
"artifacts": artItems,
|
||||
"artifact_ids": allArtifactIDs,
|
||||
"metrics": metrics,
|
||||
"tokens": metricsTokens,
|
||||
"tools": totTools,
|
||||
"elapsed_seconds": runtimeNum(diag.Metrics, "elapsed_seconds", "duration_seconds"),
|
||||
})
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// Cloud deployment control plane (unified spec §18). Manager validates,
|
||||
// gates approval, resolves credentials and audits; a Deploy Worker (not yet
|
||||
// connected) executes provider provisioning. Until a worker is wired, records
|
||||
// rest in deployment_requested / waiting_approval with executor=pending_worker.
|
||||
|
||||
var cloudDeployTargets = map[string]bool{"azure": true, "aliyun": false, "aws": false}
|
||||
var cloudDeployEnvironments = map[string]bool{"preview": true, "production": true}
|
||||
|
||||
// HeicodeDeploymentTargets lists deployable cloud targets (unified spec §18.1).
|
||||
func HeicodeDeploymentTargets(c *gin.Context) {
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"targets": []gin.H{
|
||||
{"id": "azure", "name": "Azure", "enabled": true, "supports_preview": true, "supports_production": true, "requires_approval": true, "credential_binding_required": true, "regions": []string{"eastasia", "southeastasia", "westus"}},
|
||||
{"id": "aliyun", "name": "阿里云", "enabled": false, "supports_preview": true, "supports_production": true, "requires_approval": true, "credential_binding_required": true},
|
||||
{"id": "aws", "name": "AWS", "enabled": false, "supports_preview": true, "supports_production": true, "requires_approval": true, "credential_binding_required": true},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type cloudDeployRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
ArtifactRevision int `json:"artifact_revision"`
|
||||
Target string `json:"target"`
|
||||
Environment string `json:"environment"`
|
||||
Region string `json:"region"`
|
||||
DeploymentMode string `json:"deployment_mode"`
|
||||
ResourceBindingID int `json:"resource_binding_id"`
|
||||
Options map[string]any `json:"options"`
|
||||
}
|
||||
|
||||
// HeicodeCreateDeployment validates and records a cloud deployment request
|
||||
// (unified spec §18.3/§18.4). Production deployments enter waiting_approval.
|
||||
func HeicodeCreateDeployment(c *gin.Context) {
|
||||
record, ok := requireAuthenticatedUserAgentDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req cloudDeployRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
agentError(c, "POLICY_REJECTED", err.Error())
|
||||
return
|
||||
}
|
||||
req.Target = strings.ToLower(strings.TrimSpace(req.Target))
|
||||
req.Environment = strings.ToLower(strings.TrimSpace(req.Environment))
|
||||
if strings.TrimSpace(req.ArtifactID) == "" {
|
||||
agentError(c, "POLICY_REJECTED", "artifact_id is required")
|
||||
return
|
||||
}
|
||||
if !cloudDeployTargets[req.Target] {
|
||||
agentError(c, "DEPLOY_TARGET_DISABLED", "deployment target is not available")
|
||||
return
|
||||
}
|
||||
if !cloudDeployEnvironments[req.Environment] {
|
||||
agentError(c, "POLICY_REJECTED", "environment must be preview or production")
|
||||
return
|
||||
}
|
||||
// Client must reference a credential binding, never inline secrets (§17.6).
|
||||
if req.ResourceBindingID > 0 && model.DB != nil {
|
||||
uid, _ := strconv.Atoi(strings.TrimSpace(record.Plan.UserContext.UserID))
|
||||
var binding model.ResourceBinding
|
||||
if err := model.DB.Where("id = ? AND user_id = ?", req.ResourceBindingID, uid).First(&binding).Error; err != nil {
|
||||
agentError(c, "RESOURCE_BINDING_INVALID", "resource_binding_id not found or not owned by user")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
status := "deployment_requested"
|
||||
if req.Environment == "production" {
|
||||
status = "waiting_approval"
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
runID := "deploy_" + common.GetUUID()[:12]
|
||||
payload, _ := common.Marshal(req)
|
||||
row := &model.AgentCloudDeployment{
|
||||
DeployRunID: runID,
|
||||
DeploymentID: record.DeploymentID,
|
||||
UserID: record.Plan.UserContext.UserID,
|
||||
ArtifactID: strings.TrimSpace(req.ArtifactID),
|
||||
ArtifactRevision: req.ArtifactRevision,
|
||||
Target: req.Target,
|
||||
Environment: req.Environment,
|
||||
Region: strings.TrimSpace(req.Region),
|
||||
ResourceBindingID: req.ResourceBindingID,
|
||||
Status: status,
|
||||
PayloadJSON: string(payload),
|
||||
CreatedAtMs: now,
|
||||
UpdatedAtMs: now,
|
||||
}
|
||||
if err := model.InsertAgentCloudDeployment(row); err != nil {
|
||||
common.SysLog("HeicodeCreateDeployment: " + err.Error())
|
||||
agentError(c, "DEPLOY_PERSIST_FAILED", "failed to record deployment request")
|
||||
return
|
||||
}
|
||||
recordAgentAuditEvent(agentEvent{
|
||||
EventID: "evt_" + common.GetUUID()[:12],
|
||||
Event: "deployment.requested",
|
||||
SchemaVersion: 1,
|
||||
UserID: record.Plan.UserContext.UserID,
|
||||
DeploymentID: record.DeploymentID,
|
||||
CorrelationID: record.Plan.Metadata.CorrelationID,
|
||||
OccurredAt: agentNow(),
|
||||
}, "manager_cloud_deploy", runID, "", "ok")
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"deployment_run_id": runID,
|
||||
"deployment_id": record.DeploymentID,
|
||||
"target": req.Target,
|
||||
"environment": req.Environment,
|
||||
"status": status,
|
||||
"executor": "pending_worker",
|
||||
})
|
||||
}
|
||||
|
||||
// HeicodeListDeployments lists cloud deployments for a task.
|
||||
func HeicodeListDeployments(c *gin.Context) {
|
||||
record, ok := requireAuthenticatedUserAgentDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := model.ListAgentCloudDeploymentsByDeployment(record.DeploymentID)
|
||||
if err != nil {
|
||||
agentError(c, "DEPLOY_QUERY_FAILED", "failed to query deployments")
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"items": rows, "total": len(rows)})
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// heicodeArtifactCollectionBase returns the request path up to and including the
|
||||
// `.../artifacts` collection, so per-artifact subpaths (manifest/files/archive)
|
||||
// can be built relative to however the route is mounted (sub-agile vs swarm).
|
||||
func heicodeArtifactCollectionBase(c *gin.Context) string {
|
||||
p := strings.TrimRight(c.Request.URL.Path, "/")
|
||||
if i := strings.LastIndex(p, "/artifacts"); i >= 0 {
|
||||
return p[:i] + "/artifacts"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// heicodeArtifactListItem projects a stored artifact into the client list shape.
|
||||
// For a real (non-summary) code deliverable it advertises artifact_type
|
||||
// project_folder plus manifest/files/archive subpaths, so the client never has
|
||||
// to guess whether a code_patch is a full project (unified spec §12, gap P0-3).
|
||||
func heicodeArtifactListItem(c *gin.Context, base string, a model.AgentArtifact) gin.H {
|
||||
item := gin.H{
|
||||
"artifact_id": a.ArtifactID,
|
||||
"title": a.Title,
|
||||
"summary": a.Summary,
|
||||
"artifact_type": a.ArtifactType,
|
||||
"uri": a.URI,
|
||||
"created_at_ms": a.CreatedAtMs,
|
||||
}
|
||||
if artifactIsSummaryOnly(persistedArtifactToGin(a)) {
|
||||
item["is_project"] = false
|
||||
item["display_artifact_type"] = a.ArtifactType
|
||||
return item
|
||||
}
|
||||
sub := base + "/" + a.ArtifactID
|
||||
item["is_project"] = true
|
||||
item["display_artifact_type"] = "project_folder"
|
||||
item["manifest_path"] = sub + "/manifest"
|
||||
item["files_path"] = sub + "/files"
|
||||
item["archive_path"] = sub + "/archive"
|
||||
item["revisions_path"] = sub + "/revisions"
|
||||
return item
|
||||
}
|
||||
|
||||
// HeicodeListTaskArtifacts lists a task's artifacts with project_folder
|
||||
// normalization for the primary code deliverable (unified spec §12.2).
|
||||
func HeicodeListTaskArtifacts(c *gin.Context) {
|
||||
record, ok := requireAuthenticatedUserAgentDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
record = reconcileDeploymentFromRuntime(c.Request.Context(), record)
|
||||
rows, err := model.ListAgentArtifacts(model.ListAgentArtifactsFilter{
|
||||
DeploymentID: record.DeploymentID,
|
||||
Limit: 500,
|
||||
})
|
||||
if err != nil {
|
||||
common.SysLog("HeicodeListTaskArtifacts: " + err.Error())
|
||||
agentError(c, "ARTIFACT_QUERY_FAILED", "failed to query artifacts")
|
||||
return
|
||||
}
|
||||
base := heicodeArtifactCollectionBase(c)
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, a := range rows {
|
||||
items = append(items, heicodeArtifactListItem(c, base, a))
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"deployment_id": record.DeploymentID,
|
||||
"task_id": record.DeploymentID,
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
})
|
||||
}
|
||||
|
||||
// Project-folder artifacts (unified spec §12). Runtime currently delivers a
|
||||
// single text artifact (markdown with fenced code blocks); Manager parses it
|
||||
// into a project file tree on read and serves manifest / per-file / archive,
|
||||
// so the client shows a project folder instead of one long text blob.
|
||||
|
||||
type projectFileEntry struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
MimeType string `json:"mime_type"`
|
||||
SizeBytes int `json:"size_bytes"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
Language string `json:"language,omitempty"`
|
||||
ContentPath string `json:"content_path,omitempty"`
|
||||
content string
|
||||
}
|
||||
|
||||
var (
|
||||
projectFileTokenRe = regexp.MustCompile(`[\w./\-]+\.[A-Za-z0-9]+`)
|
||||
projectCommentRe = regexp.MustCompile(`^\s*(?:#|//|/\*|<!--|;)\s*([\w./\-]+\.[A-Za-z0-9]+)`)
|
||||
)
|
||||
|
||||
func projectLangToExt(lang string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(lang)) {
|
||||
case "python", "py":
|
||||
return "py"
|
||||
case "javascript", "js":
|
||||
return "js"
|
||||
case "typescript", "ts":
|
||||
return "ts"
|
||||
case "tsx":
|
||||
return "tsx"
|
||||
case "jsx":
|
||||
return "jsx"
|
||||
case "go", "golang":
|
||||
return "go"
|
||||
case "json":
|
||||
return "json"
|
||||
case "yaml", "yml":
|
||||
return "yaml"
|
||||
case "toml":
|
||||
return "toml"
|
||||
case "bash", "sh", "shell":
|
||||
return "sh"
|
||||
case "html":
|
||||
return "html"
|
||||
case "css":
|
||||
return "css"
|
||||
case "sql":
|
||||
return "sql"
|
||||
case "markdown", "md":
|
||||
return "md"
|
||||
case "dockerfile":
|
||||
return "dockerfile"
|
||||
case "text", "plaintext", "plain", "":
|
||||
return "txt"
|
||||
default:
|
||||
return lang
|
||||
}
|
||||
}
|
||||
|
||||
func projectMimeForPath(path string) string {
|
||||
lower := strings.ToLower(path)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".json"):
|
||||
return "application/json"
|
||||
case strings.HasSuffix(lower, ".js"), strings.HasSuffix(lower, ".jsx"):
|
||||
return "application/javascript"
|
||||
case strings.HasSuffix(lower, ".ts"), strings.HasSuffix(lower, ".tsx"):
|
||||
return "text/typescript"
|
||||
case strings.HasSuffix(lower, ".md"):
|
||||
return "text/markdown"
|
||||
case strings.HasSuffix(lower, ".html"):
|
||||
return "text/html"
|
||||
case strings.HasSuffix(lower, ".css"):
|
||||
return "text/css"
|
||||
case strings.HasSuffix(lower, ".py"):
|
||||
return "text/x-python"
|
||||
case strings.HasSuffix(lower, ".go"):
|
||||
return "text/x-go"
|
||||
default:
|
||||
return "text/plain"
|
||||
}
|
||||
}
|
||||
|
||||
// parseMarkdownProject extracts files from a markdown deliverable. A file path
|
||||
// is taken from (1) a filename comment on the first code line, else (2) the
|
||||
// nearest preceding markdown header that contains a filename token, else (3) a
|
||||
// generated name. Blocks with no fenced code are ignored.
|
||||
func parseMarkdownProject(content string) []projectFileEntry {
|
||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||
entries := []projectFileEntry{}
|
||||
seen := map[string]bool{}
|
||||
headerPath := ""
|
||||
gen := 0
|
||||
i := 0
|
||||
for i < len(lines) {
|
||||
trimmed := strings.TrimSpace(lines[i])
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
if m := projectFileTokenRe.FindString(strings.Trim(trimmed, "#* `")); m != "" {
|
||||
headerPath = strings.Trim(m, "`")
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "```") {
|
||||
lang := strings.TrimSpace(strings.TrimPrefix(trimmed, "```"))
|
||||
i++
|
||||
body := []string{}
|
||||
for i < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[i]), "```") {
|
||||
body = append(body, lines[i])
|
||||
i++
|
||||
}
|
||||
i++ // closing fence
|
||||
if len(body) == 0 {
|
||||
continue
|
||||
}
|
||||
path := headerPath
|
||||
if m := projectCommentRe.FindStringSubmatch(body[0]); m != nil {
|
||||
path = m[1]
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
gen++
|
||||
path = fmt.Sprintf("file%d.%s", gen, projectLangToExt(lang))
|
||||
}
|
||||
path = strings.TrimLeft(strings.TrimSpace(path), "/")
|
||||
if seen[path] {
|
||||
gen++
|
||||
path = fmt.Sprintf("%s.%d", path, gen)
|
||||
}
|
||||
seen[path] = true
|
||||
headerPath = ""
|
||||
text := strings.Join(body, "\n")
|
||||
sum := sha256.Sum256([]byte(text))
|
||||
entries = append(entries, projectFileEntry{
|
||||
Path: path,
|
||||
Type: "file",
|
||||
MimeType: projectMimeForPath(path),
|
||||
SizeBytes: len(text),
|
||||
ContentHash: "sha256:" + hex.EncodeToString(sum[:]),
|
||||
Language: lang,
|
||||
content: text,
|
||||
})
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func fetchArtifactContentString(ctx context.Context, record agentDeploymentRecord, artifactID string) (string, error) {
|
||||
cfg := agentRuntimeClientConfigForMode(agentRuntimeModeForRecord(record))
|
||||
if !cfg.Enabled || strings.TrimSpace(cfg.BaseURL) == "" {
|
||||
return "", errors.New("runtime is not configured")
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, cfg.Timeout)
|
||||
defer cancel()
|
||||
resp, err := callAgentRuntimeArtifactContent(cctx, cfg, record, artifactID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// loadProjectArtifact resolves the authenticated deployment, fetches the
|
||||
// artifact content and parses it into a project file tree.
|
||||
func loadProjectArtifact(c *gin.Context) (agentDeploymentRecord, string, []projectFileEntry, bool) {
|
||||
record, ok := requireAuthenticatedUserAgentDeployment(c)
|
||||
if !ok {
|
||||
return record, "", nil, false
|
||||
}
|
||||
record = reconcileDeploymentFromRuntime(c.Request.Context(), record)
|
||||
artifactID := strings.TrimSpace(c.Param("artifact_id"))
|
||||
if artifactID == "" {
|
||||
agentError(c, "ARTIFACT_ID_REQUIRED", "artifact_id is required")
|
||||
return record, "", nil, false
|
||||
}
|
||||
content, err := fetchArtifactContentString(c.Request.Context(), record, artifactID)
|
||||
if err != nil {
|
||||
common.SysLog("loadProjectArtifact: " + err.Error())
|
||||
agentError(c, "ARTIFACT_CONTENT_FETCH_FAILED", "failed to fetch artifact content")
|
||||
return record, artifactID, nil, false
|
||||
}
|
||||
return record, artifactID, parseMarkdownProject(content), true
|
||||
}
|
||||
|
||||
func projectArtifactBasePath(c *gin.Context, suffix string) string {
|
||||
return strings.TrimSuffix(c.Request.URL.Path, suffix)
|
||||
}
|
||||
|
||||
// HeicodeArtifactManifest serves the project file tree (unified spec §12.3).
|
||||
func HeicodeArtifactManifest(c *gin.Context) {
|
||||
_, artifactID, files, ok := loadProjectArtifact(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
base := projectArtifactBasePath(c, "/manifest")
|
||||
tree := sha256.New()
|
||||
entries := make([]projectFileEntry, 0, len(files))
|
||||
for _, f := range files {
|
||||
// Query-param form avoids path-segment ambiguity for nested/encoded
|
||||
// paths (unified spec §12.4, gap P1-1).
|
||||
f.ContentPath = base + "/files?path=" + url.QueryEscape(f.Path)
|
||||
tree.Write([]byte(f.ContentHash))
|
||||
entries = append(entries, f)
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"artifact_id": artifactID,
|
||||
"artifact_type": "project_folder",
|
||||
"root_dir": "project",
|
||||
"revision": 1,
|
||||
"content_hash": "sha256:" + hex.EncodeToString(tree.Sum(nil)),
|
||||
"file_count": len(entries),
|
||||
"entries": entries,
|
||||
"archive": gin.H{
|
||||
"format": "zip",
|
||||
"download_path": base + "/archive",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// HeicodeArtifactFile serves a single file from the parsed project. The path is
|
||||
// taken from the ?path= query (preferred, no segment ambiguity) and falls back
|
||||
// to the trailing path segment for compatibility (unified spec §12.4).
|
||||
func HeicodeArtifactFile(c *gin.Context) {
|
||||
_, _, files, ok := loadProjectArtifact(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
want := strings.TrimSpace(c.Query("path"))
|
||||
if want == "" {
|
||||
want = strings.TrimSpace(c.Param("path"))
|
||||
}
|
||||
want = strings.Trim(want, "/")
|
||||
if want == "" {
|
||||
agentError(c, "FILE_PATH_REQUIRED", "path is required (use ?path=<file path>)")
|
||||
return
|
||||
}
|
||||
for _, f := range files {
|
||||
if f.Path == want {
|
||||
c.Data(http.StatusOK, f.MimeType+"; charset=utf-8", []byte(f.content))
|
||||
return
|
||||
}
|
||||
}
|
||||
agentError(c, "FILE_NOT_FOUND", "file not found in project artifact")
|
||||
}
|
||||
|
||||
// HeicodeArtifactArchive zips the parsed project files for download. Returns a
|
||||
// real application/zip body with Content-Disposition + Content-Length, or a
|
||||
// retryable ARTIFACT_ARCHIVE_NOT_READY error when no files have been produced
|
||||
// yet (unified spec §12.6, gap P0-4) — so the client never reports a phantom
|
||||
// "downloaded" on an empty body.
|
||||
func HeicodeArtifactArchive(c *gin.Context) {
|
||||
_, artifactID, files, ok := loadProjectArtifact(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if len(files) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": gin.H{
|
||||
"code": "ARTIFACT_ARCHIVE_NOT_READY",
|
||||
"message": "项目压缩包尚未生成(产物还未就绪),请稍后重试。",
|
||||
"retryable": true,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for _, f := range files {
|
||||
w, err := zw.Create("project/" + f.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, _ = w.Write([]byte(f.content))
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
agentError(c, "ARTIFACT_ARCHIVE_FAILED", "failed to build project archive")
|
||||
return
|
||||
}
|
||||
filename := projectArchiveFilename(artifactID)
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", buf.Len()))
|
||||
c.Data(http.StatusOK, "application/zip", buf.Bytes())
|
||||
}
|
||||
|
||||
// projectArchiveFilename builds a safe .zip filename from the artifact id.
|
||||
func projectArchiveFilename(artifactID string) string {
|
||||
name := strings.TrimSpace(artifactID)
|
||||
if name == "" {
|
||||
name = "project"
|
||||
}
|
||||
name = strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
return r
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
}, name)
|
||||
return name + ".zip"
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// consumeActiveRevision resolves the latest accepted local-edit revision for a
|
||||
// deployment and marks it applied, so a follow-up /messages or /execute runs on
|
||||
// the client's latest baseline (unified spec §12.7, gap P1-3). Returns the
|
||||
// revision number and whether one existed.
|
||||
func consumeActiveRevision(deploymentID string) (int, bool) {
|
||||
rev, ok := model.LatestAcceptedRevisionForDeployment(deploymentID)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
_ = model.DB.Model(&model.AgentArtifactRevision{}).
|
||||
Where("id = ?", rev.Id).
|
||||
Update("status", "applied").Error
|
||||
return rev.ProjectRevision, true
|
||||
}
|
||||
|
||||
// Client-facing task create + conversation flow (unified spec §5.1 / §8).
|
||||
// The client submits a requirement package (not a raw orchestration_plan);
|
||||
// Manager translates it into an orchestration_plan and creates the deployment.
|
||||
// task_id == deployment_id.
|
||||
|
||||
type heicodeRequirement struct {
|
||||
Objective string `json:"objective"`
|
||||
Context []any `json:"context"`
|
||||
Attachments []any `json:"attachments"`
|
||||
Constraints []any `json:"constraints"`
|
||||
AcceptanceCriteria []any `json:"acceptance_criteria"`
|
||||
}
|
||||
|
||||
type heicodeModelSelection struct {
|
||||
Type string `json:"type"`
|
||||
DefaultModel string `json:"default_model"`
|
||||
PrimaryModel string `json:"primary_model"`
|
||||
Roles map[string]string `json:"roles"`
|
||||
}
|
||||
|
||||
type heicodeTaskCreateBody struct {
|
||||
// Spec-preferred requirement package.
|
||||
Mode string `json:"mode"`
|
||||
ClientRole string `json:"client_role"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Requirement heicodeRequirement `json:"requirement"`
|
||||
ModelSelection heicodeModelSelection `json:"model_selection"`
|
||||
Roles []string `json:"roles"`
|
||||
// Compatibility: a raw plan may still be supplied directly.
|
||||
OrchestrationPlan *agentOrchestrationPlan `json:"orchestration_plan"`
|
||||
}
|
||||
|
||||
func buildPlanFromRequirement(c *gin.Context, body heicodeTaskCreateBody, runtimeMode string) (agentOrchestrationPlan, bool) {
|
||||
objective := strings.TrimSpace(body.Requirement.Objective)
|
||||
if objective == "" {
|
||||
agentError(c, "POLICY_REJECTED", "requirement.objective is required")
|
||||
return agentOrchestrationPlan{}, false
|
||||
}
|
||||
roles := []string{}
|
||||
for _, r := range body.Roles {
|
||||
if r = strings.TrimSpace(r); r != "" {
|
||||
roles = append(roles, r)
|
||||
}
|
||||
}
|
||||
if len(roles) == 0 {
|
||||
roles = []string{"backend"}
|
||||
}
|
||||
defaultModel := firstNonEmpty(body.ModelSelection.DefaultModel, body.ModelSelection.PrimaryModel, defaultAgentModelID())
|
||||
|
||||
allowedSeen := map[string]bool{}
|
||||
allowed := []string{}
|
||||
addAllowed := func(m string) {
|
||||
if m = strings.TrimSpace(m); m != "" && !allowedSeen[m] {
|
||||
allowedSeen[m] = true
|
||||
allowed = append(allowed, m)
|
||||
}
|
||||
}
|
||||
agents := make([]agentAgentPlan, 0, len(roles))
|
||||
runtimeAgents := make([]agentRuntimeAgent, 0, len(roles))
|
||||
for _, role := range roles {
|
||||
model := defaultModel
|
||||
if body.ModelSelection.Type == "per_role" {
|
||||
if m, ok := body.ModelSelection.Roles[role]; ok && strings.TrimSpace(m) != "" {
|
||||
model = strings.TrimSpace(m)
|
||||
}
|
||||
}
|
||||
agents = append(agents, agentAgentPlan{
|
||||
RoleTemplate: role,
|
||||
Goal: fmt.Sprintf("Execute the Heicode task as %s within the approved resource scope.", role),
|
||||
DefaultModelID: model,
|
||||
})
|
||||
runtimeAgents = append(runtimeAgents, agentRuntimeAgent{Role: role, ModelRef: model, InstanceCount: 1})
|
||||
addAllowed(model)
|
||||
}
|
||||
|
||||
correlation := firstNonEmpty(strings.TrimSpace(body.ConversationID), "conv-"+common.GetUUID()[:8])
|
||||
plan := agentOrchestrationPlan{
|
||||
IntentID: correlation,
|
||||
TemplateHint: "heicode-task",
|
||||
Objective: objective,
|
||||
SubMode: "agile",
|
||||
RiskLevel: agentRiskLow,
|
||||
Budget: agentBudget{MaxTokens: 20000, MaxCostUSD: 1, MaxDurationSec: 900},
|
||||
UserContext: agentUserContext{Role: "user"},
|
||||
BillingContext: agentBillingContext{
|
||||
Provider: "newapi",
|
||||
DefaultModelID: defaultModel,
|
||||
AllowedModelIDs: allowed,
|
||||
},
|
||||
AgentRuntime: agentAgentRuntime{Platform: "agent", Agents: runtimeAgents},
|
||||
Agents: agents,
|
||||
Constraints: agentConstraints{AllowedModelIDs: allowed},
|
||||
Metadata: agentMetadata{
|
||||
CorrelationID: correlation,
|
||||
RuntimeMode: normalizeAgentRuntimeMode(runtimeMode),
|
||||
},
|
||||
}
|
||||
return plan, true
|
||||
}
|
||||
|
||||
func heicodeCreateTask(c *gin.Context, runtimeMode string, dispatchSource string) {
|
||||
var body heicodeTaskCreateBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
agentError(c, "POLICY_REJECTED", err.Error())
|
||||
return
|
||||
}
|
||||
var plan agentOrchestrationPlan
|
||||
if body.OrchestrationPlan != nil {
|
||||
plan = *body.OrchestrationPlan
|
||||
} else {
|
||||
p, ok := buildPlanFromRequirement(c, body, runtimeMode)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
plan = p
|
||||
}
|
||||
record, ok := createAgentDeploymentFromPlan(c, plan, true, runtimeMode)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
record = maybeDispatchAgentRuntimeCreate(c, record, dispatchSource)
|
||||
common.ApiSuccess(c, withDisplayStatus(record))
|
||||
}
|
||||
|
||||
// HeicodeCreateSubAgileTask: POST /api/heicode/sub-agile/tasks
|
||||
func HeicodeCreateSubAgileTask(c *gin.Context) {
|
||||
heicodeCreateTask(c, agentRuntimeModeAgent, "heicode_client_sub_agile")
|
||||
}
|
||||
|
||||
// HeicodeCreateSwarmTask: POST /api/heicode/swarm/tasks
|
||||
func HeicodeCreateSwarmTask(c *gin.Context) {
|
||||
heicodeCreateTask(c, agentRuntimeModeSwarm, "api_swarms_adapter")
|
||||
}
|
||||
|
||||
type heicodeTaskMessage struct {
|
||||
Message string `json:"message"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// HeicodeTaskMessage records a follow-up conversation message on a task and
|
||||
// returns the current task state (unified spec §5.1 .../messages).
|
||||
func HeicodeTaskMessage(c *gin.Context) {
|
||||
record, ok := requireAuthenticatedUserAgentDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var msg heicodeTaskMessage
|
||||
_ = common.DecodeJson(c.Request.Body, &msg)
|
||||
if strings.TrimSpace(msg.Message) == "" {
|
||||
agentError(c, "POLICY_REJECTED", "message is required")
|
||||
return
|
||||
}
|
||||
recordAgentAuditEvent(agentEvent{
|
||||
EventID: "evt_" + common.GetUUID()[:12],
|
||||
Event: "task.message_received",
|
||||
SchemaVersion: 1,
|
||||
UserID: record.Plan.UserContext.UserID,
|
||||
ChannelID: record.Plan.UserContext.ChannelID,
|
||||
BindingScope: firstPlanBindingScope(record.Plan),
|
||||
DeploymentID: record.DeploymentID,
|
||||
CorrelationID: record.Plan.Metadata.CorrelationID,
|
||||
OccurredAt: agentNow(),
|
||||
}, "heicode_client_message", record.DeploymentID, agentRequestID(c), "ok")
|
||||
|
||||
record = reconcileDeploymentFromRuntime(c.Request.Context(), record)
|
||||
activeRev, hasRev := consumeActiveRevision(record.DeploymentID)
|
||||
resp := gin.H{
|
||||
"task_id": record.DeploymentID,
|
||||
"accepted": true,
|
||||
"display_status": agentDeploymentDisplayStatus(record),
|
||||
"note": "message recorded; continuation runs when the runtime supports mid-task input",
|
||||
}
|
||||
if hasRev {
|
||||
resp["active_project_revision"] = activeRev
|
||||
}
|
||||
common.ApiSuccess(c, resp)
|
||||
}
|
||||
|
||||
// HeicodeTaskExecute ensures the task is dispatched to its runtime and returns
|
||||
// the current state (unified spec §5.1 .../execute).
|
||||
func HeicodeTaskExecute(c *gin.Context) {
|
||||
record, ok := requireAuthenticatedUserAgentDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(record.RuntimeSwarmID) == "" && strings.TrimSpace(record.RuntimeDeploymentID) == "" {
|
||||
record = maybeDispatchAgentRuntimeCreate(c, record, "heicode_client_execute")
|
||||
}
|
||||
_, _ = consumeActiveRevision(record.DeploymentID)
|
||||
record = reconcileDeploymentFromRuntime(c.Request.Context(), record)
|
||||
common.ApiSuccess(c, withDisplayStatus(record))
|
||||
}
|
||||
|
||||
// heicodeListTasksByMode lists the authenticated user's tasks filtered to a
|
||||
// single client mode (sub_agile | swarm), so /api/heicode/sub-agile/tasks no
|
||||
// longer leaks swarm tasks and vice versa. Each item carries display_status +
|
||||
// mode for the client.
|
||||
func heicodeListTasksByMode(c *gin.Context, mode string) {
|
||||
userID := fmt.Sprintf("%d", c.GetInt("id"))
|
||||
bindingScope := strings.TrimSpace(c.Query("binding_scope"))
|
||||
items := listAgentDeploymentRecords(userID, bindingScope)
|
||||
filtered := make([]agentDeploymentRecord, 0, len(items))
|
||||
for _, it := range items {
|
||||
it = withDisplayStatus(it)
|
||||
if it.Mode == mode {
|
||||
filtered = append(filtered, it)
|
||||
}
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"items": filtered, "total": len(filtered)})
|
||||
}
|
||||
|
||||
// HeicodeListSubAgileTasks: GET /api/heicode/sub-agile/tasks (sub_agile only).
|
||||
func HeicodeListSubAgileTasks(c *gin.Context) {
|
||||
heicodeListTasksByMode(c, "sub_agile")
|
||||
}
|
||||
|
||||
// HeicodeListSwarmTasks: GET /api/heicode/swarm/tasks (swarm only).
|
||||
func HeicodeListSwarmTasks(c *gin.Context) {
|
||||
heicodeListTasksByMode(c, "swarm")
|
||||
}
|
||||
|
||||
// HeicodeDeleteTask hard-deletes a task: best-effort stop at the runtime, then
|
||||
// remove the deployment record so it disappears from the list (unified spec
|
||||
// §5.1 DELETE .../tasks/{id}).
|
||||
func HeicodeDeleteTask(c *gin.Context) {
|
||||
record, ok := requireAuthenticatedUserAgentDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Best-effort runtime stop; never block the delete on a runtime error.
|
||||
cfg := agentRuntimeClientConfigForMode(agentRuntimeModeForRecord(record))
|
||||
if cfg.Enabled && strings.TrimSpace(record.RuntimeDeploymentID) != "" {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), cfg.Timeout)
|
||||
_, _ = callAgentRuntimeStop(ctx, cfg, record, "deleted by user")
|
||||
cancel()
|
||||
}
|
||||
agentMu.Lock()
|
||||
delete(agentDeployments, record.DeploymentID)
|
||||
agentMu.Unlock()
|
||||
if err := model.DeleteAgentDeployment(record.DeploymentID); err != nil {
|
||||
common.SysLog("HeicodeDeleteTask: " + err.Error())
|
||||
agentError(c, "DEPLOYMENT_DELETE_FAILED", "failed to delete task")
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"deployment_id": record.DeploymentID, "deleted": true})
|
||||
}
|
||||
@@ -63,8 +63,6 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.POST("/agent/callbacks/runtime-events", controller.AgentReceiveRuntimeEventCallback)
|
||||
// Client-facing capability discovery (unified spec §6). Catalog data only.
|
||||
apiRouter.GET("/heicode/capabilities", controller.HeicodeCapabilities)
|
||||
// Cloud deployment target discovery (unified spec §18.1).
|
||||
apiRouter.GET("/heicode/deployment-targets", middleware.UserOrV2DeviceAuth(), controller.HeicodeDeploymentTargets)
|
||||
apiRouter.POST("/swarms", middleware.UserOrV2DeviceAuth(), controller.AgentCreateUserSwarm)
|
||||
//apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook)
|
||||
|
||||
@@ -523,49 +521,6 @@ func SetApiRouter(router *gin.Engine) {
|
||||
agentApprovalRoute.POST("/user/tasks/:task_id/deployment-draft", controller.AgentCreateTaskDeploymentDraft)
|
||||
}
|
||||
|
||||
// Client-facing unified task routes (unified spec §5.1). task_id == the
|
||||
// deployment_id, so these reuse the deployment control-plane handlers.
|
||||
// Sub Agile -> agent_management; Swarm -> HeiCode-Swarm (mode is carried
|
||||
// by the deployment record created at POST /tasks).
|
||||
registerHeicodeTaskRoutes := func(group *gin.RouterGroup, createHandler gin.HandlerFunc, listHandler gin.HandlerFunc) {
|
||||
group.GET("/tasks", listHandler)
|
||||
group.POST("/tasks", createHandler)
|
||||
group.GET("/tasks/:deployment_id", controller.AgentGetUserDeployment)
|
||||
group.DELETE("/tasks/:deployment_id", controller.HeicodeDeleteTask)
|
||||
group.POST("/tasks/:deployment_id/messages", controller.HeicodeTaskMessage)
|
||||
group.POST("/tasks/:deployment_id/execute", controller.HeicodeTaskExecute)
|
||||
group.POST("/tasks/:deployment_id/stop", controller.AgentStopUserDeployment)
|
||||
group.GET("/tasks/:deployment_id/timeline", controller.AgentGetUserDeploymentTimeline)
|
||||
group.GET("/tasks/:deployment_id/workflow", controller.HeicodeTaskWorkflow)
|
||||
group.GET("/tasks/:deployment_id/logs", controller.AgentListUserDeploymentLogs)
|
||||
group.GET("/tasks/:deployment_id/events", controller.AgentListUserDeploymentEvents)
|
||||
group.GET("/tasks/:deployment_id/metrics", controller.AgentGetUserDeploymentMetrics)
|
||||
group.GET("/tasks/:deployment_id/diagnostics", controller.AgentGetUserDeploymentRuntimeDiagnostics)
|
||||
group.GET("/tasks/:deployment_id/artifacts", controller.HeicodeListTaskArtifacts)
|
||||
group.GET("/tasks/:deployment_id/artifacts/:artifact_id/content", controller.AgentGetUserDeploymentArtifactContent)
|
||||
group.GET("/tasks/:deployment_id/artifacts/:artifact_id/manifest", controller.HeicodeArtifactManifest)
|
||||
group.GET("/tasks/:deployment_id/artifacts/:artifact_id/archive", controller.HeicodeArtifactArchive)
|
||||
group.GET("/tasks/:deployment_id/artifacts/:artifact_id/files", controller.HeicodeArtifactFile)
|
||||
group.GET("/tasks/:deployment_id/artifacts/:artifact_id/revisions", controller.HeicodeArtifactRevisions)
|
||||
group.POST("/tasks/:deployment_id/artifacts/:artifact_id/local-edits", controller.HeicodeArtifactLocalEdit)
|
||||
group.POST("/tasks/:deployment_id/artifacts/:artifact_id/local-edits/batch", controller.HeicodeArtifactLocalEdit)
|
||||
group.GET("/tasks/:deployment_id/sk-snapshots", controller.AgentListUserSKSnapshots)
|
||||
group.GET("/tasks/:deployment_id/approvals", controller.HeicodeListTaskApprovals)
|
||||
group.POST("/tasks/:deployment_id/approvals/:approval_id/approve", controller.ApproveAgentApprovalRequest)
|
||||
group.POST("/tasks/:deployment_id/approvals/:approval_id/reject", controller.RejectAgentApprovalRequest)
|
||||
// Cloud deployment of the project artifact (unified spec §18).
|
||||
group.POST("/tasks/:deployment_id/deployments", controller.HeicodeCreateDeployment)
|
||||
group.GET("/tasks/:deployment_id/deployments", controller.HeicodeListDeployments)
|
||||
}
|
||||
|
||||
heicodeSubAgileRoute := apiRouter.Group("/heicode/sub-agile")
|
||||
heicodeSubAgileRoute.Use(middleware.UserOrV2DeviceAuth())
|
||||
registerHeicodeTaskRoutes(heicodeSubAgileRoute, controller.HeicodeCreateSubAgileTask, controller.HeicodeListSubAgileTasks)
|
||||
|
||||
heicodeSwarmRoute := apiRouter.Group("/heicode/swarm")
|
||||
heicodeSwarmRoute.Use(middleware.UserOrV2DeviceAuth())
|
||||
registerHeicodeTaskRoutes(heicodeSwarmRoute, controller.HeicodeCreateSwarmTask, controller.HeicodeListSwarmTasks)
|
||||
|
||||
// Template-agent model (new): deploy a template agent with bound resources
|
||||
// injected as env; client reads the agent list and connects to the agent's
|
||||
// subdomain directly. Same V2-device / session auth as the task routes.
|
||||
|
||||
Reference in New Issue
Block a user