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:
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user