Files
heicode-mananger/heicode/controller/heicode_artifact_edits.go
T
chenchenandClaude Opus 4.8 56eef58b3c feat(agent): project-folder artifacts, local-edit revisions, cloud-deploy control plane
完成统一方案 v0.1 剩余客户端要求(#10/#11/#12)+ 对接文档。

- #10 项目文件夹产物(§12):Manager 解析 runtime 的 markdown 多文件 artifact 成项目文件树,
  新增 .../artifacts/{id}/manifest、/files/{path}、/archive 三接口(按需解析,zip 打包)。
- #11 本地修改 revision 协议(§12.7):新模型 AgentArtifactRevision + 迁移;
  .../local-edits、/local-edits/batch、/revisions;base_revision 冲突检测返回
  ARTIFACT_REVISION_CONFLICT;Manager 持有 accepted 基线,回调 Runtime(审计事件)。
- #12 云部署控制面(§18):新模型 AgentCloudDeployment + 迁移;
  GET /api/heicode/deployment-targets;.../tasks/{id}/deployments(创建/列表);
  生产环境进 waiting_approval;客户端只传 resource_binding_id(禁 inline secret);
  真实云执行留 executor=pending_worker,等 Deploy Worker 接入。
- 文档:新增 docs/integration/heicode-desktop-unified-api.md(取代旧 sub-agile 文档,
  覆盖 capabilities/统一任务路由/display_status/项目文件夹/本地修改/云部署/客户端约束/错误码)。

验证:go build ./... + go test(controller/router/model/middleware)全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 00:23:02 +08:00

178 lines
5.6 KiB
Go

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",
Status: "received",
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": "received",
"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")
}