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>
This commit is contained in:
2026-06-02 00:23:02 +08:00
co-authored by Claude Opus 4.8
parent 443f552917
commit 56eef58b3c
9 changed files with 881 additions and 0 deletions
@@ -0,0 +1,169 @@
# Heicode 桌面客户端统一接口对接文档(v0.1)
更新时间:2026-06-02
适用范围:Heicode Desktop 对接 Heicode Manager 的**统一接口层**(Sub Agile / Swarm 两模式)。
Manager 生产地址:`https://code.xinghanlab.com`
基准:统一调用方案 v0.1 + agent_management Sub Mode Runtime 对接指南。
> 本文取代旧的 `heicode-desktop-sub-agile-api.md`(其 `/api/agnet/*` 路由已废弃)。新客户端一律使用本文的 `/api/heicode/*` 与 `/api/agent/*` 接口。
## 0. 命名与总原则
- **命名统一**:`agnet` 是历史拼写错误,已全量改为 `agent`。所有新接口为 `/api/agent/*`、`/api/heicode/*`;旧 `/api/agnet/*` 已下线(404)。
- **客户端只调 Manager**:禁止直连 agent_management / HeiCode-Swarm / NewAPI / Runtime artifact 接口。
- **Manager 是唯一状态裁判**:客户端只消费 `display_status`,不自行用正则判断产物是否有效。
- **task_id ≡ deployment_id**:统一任务接口里 `{task_id}` 即 Manager 的 `deployment_id`。
## 1. 认证
| 方式 | 适用 |
|---|---|
| V2 加密 body + 设备签名(`Content-Encoding: heicode-aead-v1` + `X-Heicode-*`) | 桌面客户端 POST/有 body 请求(与模型调用同一套 `encryptedFetch`) |
| Manager session cookie + `New-Api-User: <user_id>` | Web 控制台 / 未加密 GET 查询兼容路径 |
加密/签名协议同旧文档 §2.2(未变)。
## 2. 能力发现
### `GET /api/heicode/capabilities`
无需登录,返回模式与模型目录。
```json
{ "success": true, "data": {
"modes": [
{"id":"sub_agile","name":"Sub Agile","runtime_kind":"agent_management","model_selection":"per_role","supports_roles":true,"supports_task_graph":false,"supports_artifacts":true,"supports_continue_chat":true,"enabled":true},
{"id":"swarm","name":"Swarm","runtime_kind":"heicode_swarm","model_selection":"primary","supports_roles":false,"supports_task_graph":true,"supports_artifacts":true,"supports_continue_chat":true,"enabled":false}
],
"models": [{"id":"gpt-5.4","name":"gpt-5.4","available":true}]
}}
```
`enabled` 反映该模式 Runtime 当前是否接通。
## 3. 统一任务接口
两套前缀,按模式选择:`/api/heicode/sub-agile/*`(→ agent_management)、`/api/heicode/swarm/*`(→ HeiCode-Swarm)。下表以 sub-agile 为例,swarm 同形。
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/heicode/sub-agile/tasks` | 创建任务(body 见 §3.1) |
| GET | `/api/heicode/sub-agile/tasks` | 任务列表 |
| GET | `/api/heicode/sub-agile/tasks/{task_id}` | 任务详情(含 `display_status`,读取时自动从 Runtime 收敛) |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/workflow` | 工作流投影(右侧面板:status/agents/artifacts/phase) |
| POST | `/api/heicode/sub-agile/tasks/{task_id}/stop` | 停止 |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/timeline` | 时间线(events/callbacks/artifacts 聚合) |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/logs` | 日志(含 `user_logs` 友好 + `debug_logs` 原始两层) |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/events` | 事件 |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/metrics` | 指标 |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/diagnostics` | 诊断(直查 Runtime 状态 + warnings) |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/sk-snapshots` | SK 快照 |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/artifacts` | 产物列表 |
| POST | `/api/heicode/sub-agile/tasks/{task_id}/approvals/{approval_id}/approve` | 同意审批 |
| POST | `/api/heicode/sub-agile/tasks/{task_id}/approvals/{approval_id}/reject` | 拒绝审批 |
### 3.1 创建 body(orchestration_plan)
```json
{ "orchestration_plan": {
"intent_id": "task-xxx",
"template_hint": "heicode-task",
"objective": "做一个待办系统 MVP",
"sub_mode": "agile",
"risk_level": "low",
"budget": {"max_tokens":20000,"max_cost_usd":1,"max_duration_sec":600},
"user_context": {"user_id":"22","channel_id":"default"},
"billing_context": {"provider":"newapi","default_model_id":"gpt-5.4","allowed_model_ids":["gpt-5.4"]},
"agents": [{"role_template":"backend","goal":"...","default_model_id":"gpt-5.4","resource_grants":[]}],
"constraints": {"allowed_model_ids":["gpt-5.4"]},
"metadata": {"correlation_id":"task-xxx-1"}
}}
```
模型必须用生产 NewAPI 已存在模型(当前推荐 `gpt-5.4`)。
## 4. 状态模型(display_status)
客户端只展示 `display_status`(Manager 裁决结果):
| display_status | 含义 |
|---|---|
| `accepted` / `running` / `waiting_approval` | 进行中 |
| `completed` | 完成且有有效交付物 |
| `completed_without_deliverable` | Runtime 完成但无产物 |
| `needs_codegen` | 只有方案/总结,需继续生成代码 |
| `failed` / `stopped` | 终态 |
裁决规则:`completed` 且存在非兜底(`metadata.synthesized!=true` 且非纯总结)的真实 artifact → `completed`;否则降级为 `needs_codegen`(有总结)或 `completed_without_deliverable`(无产物)。
## 5. 产物:项目文件夹
Runtime 返回单个文本/markdown 多文件 artifact;Manager 解析成项目文件树后提供:
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `.../artifacts/{artifact_id}/content` | 原始正文(兼容) |
| GET | `.../artifacts/{artifact_id}/manifest` | 项目文件树(entries[path,type,mime,size,hash,content_path] + archive) |
| GET | `.../artifacts/{artifact_id}/files/{path}` | 单个文件正文 |
| GET | `.../artifacts/{artifact_id}/archive` | 整个项目 zip 下载 |
manifest 示例:
```json
{ "success": true, "data": {
"artifact_id":"art_xxx","artifact_type":"project_folder","root_dir":"project","revision":1,
"content_hash":"sha256:...","file_count":2,
"entries":[
{"path":"app.py","type":"file","mime_type":"text/x-python","size_bytes":420,"content_hash":"sha256:...","content_path":".../files/app.py"},
{"path":"test_app.py","type":"file","mime_type":"text/x-python","size_bytes":300,"content_hash":"sha256:...","content_path":".../files/test_app.py"}
],
"archive":{"format":"zip","download_path":".../archive"}
}}
```
## 6. 本地修改回传(revision)
用户在本地改了产物后,必须显式上传;Manager 存为新 revision。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `.../artifacts/{artifact_id}/revisions` | revision 列表 + `current_project_revision` |
| POST | `.../artifacts/{artifact_id}/local-edits` | 上传单文件修改 |
| POST | `.../artifacts/{artifact_id}/local-edits/batch` | 上传多文件修改(`changes[]`,op=update/create/delete) |
请求体核心:`base_revision`(基线版本)、`path`/`content` 或 `changes[]`、`change_summary`。
冲突:若 `base_revision` 不等于当前最新,返回 `ARTIFACT_REVISION_CONFLICT` + `data.current_project_revision`,客户端提示用户先同步。
## 7. 云部署(控制面,执行待 Deploy Worker)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/heicode/deployment-targets` | 可部署云目标(azure 启用;aliyun/aws 预留) |
| POST | `/api/heicode/sub-agile/tasks/{task_id}/deployments` | 发起部署(body: artifact_id/target/environment/region/resource_binding_id/options) |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/deployments` | 部署列表 |
客户端**禁止**传云厂商 AccessKey/Secret/连接串/secret_ref,只传 `resource_binding_id`(Manager 内部解析为凭据)。
`environment=production` 进入 `waiting_approval`;当前响应带 `executor: "pending_worker"`(真实云执行待 Deploy Worker 接入)。
## 8. Runtime 回调(仅 Runtime 用,客户端无需关心)
Runtime → Manager 统一回调:`POST /api/agent/callbacks/runtime-events`。
Schema 查询:`GET /api/agent/callbacks/runtime-events/schema`。
## 9. 客户端生产约束
- 审批 approve/reject 必须调 Manager,禁止本地伪造。
- 生产包禁用 mock 任务/审批/产物。
- 只消费 Manager 的 `display_status` / manifest / diagnostics。
- 普通用户文案不出现 `newapi`、上游厂商、模型网关字眼。
- 任务接口认证用 deviceToken / Manager session;base_url 来自 preset。
## 10. 错误码(新增)
| code | 场景 |
|---|---|
| `ARTIFACT_REVISION_CONFLICT` | 本地修改基于旧版本 |
| `RESOURCE_BINDING_INVALID` | resource_binding_id 不存在或非本人 |
| `DEPLOY_TARGET_DISABLED` | 云目标未启用 |
| `FILE_NOT_FOUND` | 项目文件不存在 |
其余错误码沿用旧文档 §13。
@@ -150,3 +150,18 @@ func TestDefaultAgentModelID_SingleSourceNoPlaceholder(t *testing.T) {
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)
}
}
@@ -0,0 +1,177 @@
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")
}
+139
View File
@@ -0,0 +1,139 @@
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)})
}
@@ -0,0 +1,278 @@
package controller
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
)
// 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 {
f.ContentPath = base + "/files/" + 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.
func HeicodeArtifactFile(c *gin.Context) {
_, _, files, ok := loadProjectArtifact(c)
if !ok {
return
}
want := strings.Trim(strings.TrimSpace(c.Param("path")), "/")
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.
func HeicodeArtifactArchive(c *gin.Context) {
_, artifactID, files, ok := loadProjectArtifact(c)
if !ok {
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))
}
_ = zw.Close()
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip", artifactID))
c.Data(http.StatusOK, "application/zip", buf.Bytes())
}
+44
View File
@@ -0,0 +1,44 @@
package model
// AgentArtifactRevision stores client-submitted local edits to a project
// artifact as monotonic revisions (unified spec §12.7). Manager is the owner of
// the accepted-revision baseline; Runtime reads new revisions via Manager.
type AgentArtifactRevision struct {
Id int `gorm:"primaryKey" json:"id"`
ArtifactID string `gorm:"type:varchar(128);index" json:"artifact_id"`
DeploymentID string `gorm:"type:varchar(64);index" json:"deployment_id"`
UserID string `gorm:"type:varchar(64);index" json:"user_id"`
Revision int `gorm:"index" json:"revision"`
ProjectRevision int `json:"project_revision"`
Source string `gorm:"type:varchar(64)" json:"source"`
BaseContentHash string `gorm:"type:varchar(128)" json:"base_content_hash"`
ContentHash string `gorm:"type:varchar(128)" json:"content_hash"`
ChangeSummary string `gorm:"type:varchar(512)" json:"change_summary"`
CreatedBy string `gorm:"type:varchar(64)" json:"created_by"`
Status string `gorm:"type:varchar(32)" json:"status"`
PayloadJSON string `gorm:"type:text" json:"-"`
CreatedAtMs int64 `gorm:"bigint;index" json:"created_at"`
}
func (AgentArtifactRevision) TableName() string {
return "agent_artifact_revisions"
}
func InsertAgentArtifactRevision(r *AgentArtifactRevision) error {
return DB.Create(r).Error
}
func ListAgentArtifactRevisions(artifactID string) ([]AgentArtifactRevision, error) {
var rows []AgentArtifactRevision
err := DB.Where("artifact_id = ?", artifactID).Order("revision asc, id asc").Find(&rows).Error
return rows, err
}
// LatestAgentArtifactRevision returns the highest-revision row for an artifact.
func LatestAgentArtifactRevision(artifactID string) (AgentArtifactRevision, bool) {
var row AgentArtifactRevision
if err := DB.Where("artifact_id = ?", artifactID).Order("revision desc, id desc").First(&row).Error; err != nil {
return AgentArtifactRevision{}, false
}
return row, true
}
+46
View File
@@ -0,0 +1,46 @@
package model
// AgentCloudDeployment is the Manager-side control-plane record for deploying a
// project_folder artifact to a cloud target (unified spec §18). Manager owns
// validation/approval/credential-resolution/audit; actual provisioning is
// executed by a Deploy Worker (not yet connected — records sit in a requested/
// waiting state until a worker picks them up).
type AgentCloudDeployment struct {
Id int `gorm:"primaryKey" json:"id"`
DeployRunID string `gorm:"type:varchar(64);uniqueIndex" json:"deployment_run_id"`
DeploymentID string `gorm:"type:varchar(64);index" json:"deployment_id"`
UserID string `gorm:"type:varchar(64);index" json:"user_id"`
ArtifactID string `gorm:"type:varchar(128)" json:"artifact_id"`
ArtifactRevision int `json:"artifact_revision"`
Target string `gorm:"type:varchar(32);index" json:"target"`
Environment string `gorm:"type:varchar(32)" json:"environment"`
Region string `gorm:"type:varchar(64)" json:"region"`
ResourceBindingID int `json:"resource_binding_id"`
Status string `gorm:"type:varchar(32);index" json:"status"`
URL string `gorm:"type:varchar(512)" json:"url"`
PayloadJSON string `gorm:"type:text" json:"-"`
CreatedAtMs int64 `gorm:"bigint;index" json:"created_at"`
UpdatedAtMs int64 `gorm:"bigint" json:"updated_at"`
}
func (AgentCloudDeployment) TableName() string {
return "agent_cloud_deployments"
}
func InsertAgentCloudDeployment(r *AgentCloudDeployment) error {
return DB.Create(r).Error
}
func ListAgentCloudDeploymentsByDeployment(deploymentID string) ([]AgentCloudDeployment, error) {
var rows []AgentCloudDeployment
err := DB.Where("deployment_id = ?", deploymentID).Order("id desc").Find(&rows).Error
return rows, err
}
func GetAgentCloudDeployment(deployRunID string, userID string) (AgentCloudDeployment, bool) {
var row AgentCloudDeployment
if err := DB.Where("deploy_run_id = ? AND user_id = ?", deployRunID, userID).First(&row).Error; err != nil {
return AgentCloudDeployment{}, false
}
return row, true
}
+2
View File
@@ -324,6 +324,8 @@ func migrateDB() error {
&AgentDeployment{},
&AgentCallbackEvent{},
&AgentArtifact{},
&AgentArtifactRevision{},
&AgentCloudDeployment{},
&AgentSKSnapshot{},
// V2 device-binding: X25519 keypair the Manager uses for ECDH
// body decryption. See model/server_key.go.
+11
View File
@@ -63,6 +63,8 @@ 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)
@@ -538,9 +540,18 @@ func SetApiRouter(router *gin.Engine) {
group.GET("/tasks/:deployment_id/diagnostics", controller.AgentGetUserDeploymentRuntimeDiagnostics)
group.GET("/tasks/:deployment_id/artifacts", controller.AgentListUserDeploymentArtifacts)
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/*path", 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.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")