feat: add manager sub deployment bridge

This commit is contained in:
gongzhiyong
2026-05-26 18:23:23 +08:00
parent 6562acaa62
commit 0abd761d66
10 changed files with 1308 additions and 177 deletions
+233 -1
View File
@@ -18,6 +18,9 @@ const (
agnetRiskMedium = "medium"
agnetRiskHigh = "high"
agnetSubModeAgile = "agile"
agnetSubModeWaterfall = "waterfall"
agnetResourceGit = "git"
agnetResourceSK = "sk"
agnetResourceProjectDoc = "project_doc"
@@ -135,6 +138,7 @@ type agnetOrchestrationPlan struct {
IntentID string `json:"intent_id"`
TemplateHint string `json:"template_hint"`
Objective string `json:"objective"`
SubMode string `json:"sub_mode"`
RiskLevel string `json:"risk_level"`
Budget agnetBudget `json:"budget"`
UserContext agnetUserContext `json:"user_context"`
@@ -151,6 +155,7 @@ type agnetDeploymentRequest struct {
type agnetDeploymentRecord struct {
DeploymentID string `json:"deployment_id"`
SubMode string `json:"sub_mode"`
Status string `json:"status"`
Phase string `json:"phase"`
RuntimeState string `json:"runtime_state"`
@@ -205,6 +210,10 @@ type agnetSKSnapshotResolveRequest struct {
DeploymentID string `json:"deployment_id"`
}
type agnetSimulateDeploymentEventsRequest struct {
Events []string `json:"events"`
}
type agnetSKSnapshot struct {
SnapshotID string `json:"snapshot_id"`
DeploymentID string `json:"deployment_id"`
@@ -290,6 +299,7 @@ func marshalAgnetSnapshot(v any) (string, error) {
func agnetDeploymentModelToRecord(row model.AgnetDeployment) (agnetDeploymentRecord, error) {
var record agnetDeploymentRecord
record.DeploymentID = row.DeploymentID
record.SubMode = normalizeAgnetSubMode(row.SubMode)
record.Status = row.Status
record.Phase = row.Phase
record.RuntimeState = row.RuntimeState
@@ -348,6 +358,7 @@ func persistAgnetDeploymentRecord(record agnetDeploymentRecord, req agnetDeploym
ChannelID: record.Plan.UserContext.ChannelID,
BindingScope: firstPlanBindingScope(record.Plan),
CorrelationID: record.Plan.Metadata.CorrelationID,
SubMode: normalizeAgnetSubMode(record.SubMode),
Status: record.Status,
Phase: record.Phase,
RuntimeState: record.RuntimeState,
@@ -380,6 +391,7 @@ func updateAgnetDeploymentRecord(record agnetDeploymentRecord) error {
Where("deployment_id = ?", record.DeploymentID).
Updates(map[string]any{
"status": record.Status,
"sub_mode": normalizeAgnetSubMode(record.SubMode),
"phase": record.Phase,
"runtime_state": record.RuntimeState,
"failure_reason": record.FailureReason,
@@ -390,6 +402,24 @@ func updateAgnetDeploymentRecord(record agnetDeploymentRecord) error {
}).Error
}
func normalizeAgnetSubMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case agnetSubModeWaterfall:
return agnetSubModeWaterfall
default:
return agnetSubModeAgile
}
}
func isValidAgnetSubMode(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", agnetSubModeAgile, agnetSubModeWaterfall:
return true
default:
return false
}
}
func findAgnetDeploymentRecord(deploymentID string) (agnetDeploymentRecord, bool) {
agnetMu.RLock()
record, ok := agnetDeployments[deploymentID]
@@ -716,6 +746,10 @@ func validateOrchestrationPlan(c *gin.Context, plan agnetOrchestrationPlan) bool
agnetError(c, "POLICY_REJECTED", "risk_level must be low/medium/high")
return false
}
if !isValidAgnetSubMode(plan.SubMode) {
agnetError(c, "POLICY_REJECTED", "sub_mode must be agile or waterfall")
return false
}
if plan.Budget.MaxTokens <= 0 || plan.Budget.MaxCostUSD <= 0 || plan.Budget.MaxDurationSec <= 0 {
agnetError(c, "POLICY_REJECTED", "budget.max_tokens/max_cost_usd/max_duration_sec must be positive")
return false
@@ -881,22 +915,88 @@ func applyAuthenticatedManagerUserContext(c *gin.Context, plan *agnetOrchestrati
}
}
func AgnetCreateDeployment(c *gin.Context) {
func enforceAuthenticatedAgnetUserContext(c *gin.Context, plan *agnetOrchestrationPlan) bool {
if plan == nil {
return false
}
authenticatedUserID := c.GetInt("id")
if authenticatedUserID <= 0 {
agnetError(c, "POLICY_REJECTED", "authenticated user is required")
return false
}
userID := strconv.Itoa(authenticatedUserID)
if requestedUserID := strings.TrimSpace(plan.UserContext.UserID); requestedUserID != "" && requestedUserID != userID {
agnetError(c, "USER_CONTEXT_FORBIDDEN", "user_context.user_id must match authenticated user")
return false
}
plan.UserContext.UserID = userID
if strings.TrimSpace(plan.UserContext.ChannelID) == "" {
if group, ok := c.Get("group"); ok {
if channelID, ok := group.(string); ok {
plan.UserContext.ChannelID = strings.TrimSpace(channelID)
}
}
}
for agentIdx := range plan.Agents {
for grantIdx := range plan.Agents[agentIdx].ResourceGrants {
grantUserID := strings.TrimSpace(plan.Agents[agentIdx].ResourceGrants[grantIdx].UserID)
if grantUserID != "" && grantUserID != userID {
agnetError(c, "RESOURCE_GRANT_FORBIDDEN", "resource_grants.user_id must match authenticated user")
return false
}
plan.Agents[agentIdx].ResourceGrants[grantIdx].UserID = userID
}
}
return true
}
func agnetDeploymentBelongsToAuthenticatedUser(c *gin.Context, record agnetDeploymentRecord) bool {
userID := strconv.Itoa(c.GetInt("id"))
return userID != "0" && strings.TrimSpace(record.Plan.UserContext.UserID) == userID
}
func requireAuthenticatedUserAgnetDeployment(c *gin.Context) (agnetDeploymentRecord, bool) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
agnetError(c, "POLICY_REJECTED", "deployment_id is required")
return agnetDeploymentRecord{}, false
}
record, ok := findAgnetDeploymentRecord(deploymentID)
if !ok {
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
return agnetDeploymentRecord{}, false
}
if !agnetDeploymentBelongsToAuthenticatedUser(c, record) {
agnetError(c, "DEPLOYMENT_FORBIDDEN", "deployment does not belong to authenticated user")
return agnetDeploymentRecord{}, false
}
return record, true
}
func createAgnetDeployment(c *gin.Context, enforceUserScope bool) {
var req agnetDeploymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
agnetError(c, "POLICY_REJECTED", err.Error())
return
}
plan := req.Plan
if enforceUserScope {
if !enforceAuthenticatedAgnetUserContext(c, &plan) {
return
}
} else {
applyAuthenticatedManagerUserContext(c, &plan)
}
if !validateOrchestrationPlan(c, plan) {
return
}
plan.SubMode = normalizeAgnetSubMode(plan.SubMode)
now := agnetNow()
deploymentID := "dep_" + common.GetUUID()[:12]
record := agnetDeploymentRecord{
DeploymentID: deploymentID,
SubMode: plan.SubMode,
Status: "accepted",
Phase: "pending",
RuntimeState: "queued",
@@ -931,6 +1031,7 @@ func AgnetCreateDeployment(c *gin.Context) {
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
"sub_mode": record.SubMode,
"status": record.Status,
"phase": record.Phase,
"runtime_state": record.RuntimeState,
@@ -940,6 +1041,14 @@ func AgnetCreateDeployment(c *gin.Context) {
})
}
func AgnetCreateDeployment(c *gin.Context) {
createAgnetDeployment(c, false)
}
func AgnetCreateUserDeployment(c *gin.Context) {
createAgnetDeployment(c, true)
}
func AgnetGetDeployment(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
@@ -954,6 +1063,14 @@ func AgnetGetDeployment(c *gin.Context) {
common.ApiSuccess(c, record)
}
func AgnetGetUserDeployment(c *gin.Context) {
record, ok := requireAuthenticatedUserAgnetDeployment(c)
if !ok {
return
}
common.ApiSuccess(c, record)
}
func AgnetListDeployments(c *gin.Context) {
userID := strings.TrimSpace(c.Query("user_id"))
bindingScope := strings.TrimSpace(c.Query("binding_scope"))
@@ -965,6 +1082,16 @@ func AgnetListDeployments(c *gin.Context) {
})
}
func AgnetListUserDeployments(c *gin.Context) {
userID := strconv.Itoa(c.GetInt("id"))
bindingScope := strings.TrimSpace(c.Query("binding_scope"))
items := listAgnetDeploymentRecords(userID, bindingScope)
common.ApiSuccess(c, gin.H{
"items": items,
"total": len(items),
})
}
func AgnetStopDeployment(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
@@ -1016,6 +1143,13 @@ func AgnetStopDeployment(c *gin.Context) {
})
}
func AgnetStopUserDeployment(c *gin.Context) {
if _, ok := requireAuthenticatedUserAgnetDeployment(c); !ok {
return
}
AgnetStopDeployment(c)
}
func AgnetListDeploymentEvents(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
@@ -1039,6 +1173,90 @@ func AgnetListDeploymentEvents(c *gin.Context) {
})
}
func AgnetListUserDeploymentEvents(c *gin.Context) {
if _, ok := requireAuthenticatedUserAgnetDeployment(c); !ok {
return
}
AgnetListDeploymentEvents(c)
}
func normalizeAgnetSimulationEvents(values []string) []string {
events := make([]string, 0, len(values))
for _, value := range values {
event := strings.TrimSpace(value)
if event == "" {
continue
}
if !strings.HasPrefix(event, "simulation.") {
event = "simulation." + event
}
events = append(events, event)
}
if len(events) == 0 {
return []string{
"simulation.deployment.started",
"simulation.agent.progress",
"simulation.deployment.completed",
}
}
return events
}
func AgnetSimulateUserDeploymentEvents(c *gin.Context) {
record, ok := requireAuthenticatedUserAgnetDeployment(c)
if !ok {
return
}
var req agnetSimulateDeploymentEventsRequest
if c.Request != nil && c.Request.Body != nil {
if err := c.ShouldBindJSON(&req); err != nil {
agnetError(c, "POLICY_REJECTED", err.Error())
return
}
}
events := normalizeAgnetSimulationEvents(req.Events)
now := agnetNow()
record.Phase = "simulated"
record.RuntimeState = "simulated"
record.FailureReason = ""
record.UpdatedAt = now
for i := range record.AgentInstances {
record.AgentInstances[i].Phase = record.Phase
record.AgentInstances[i].RuntimeState = record.RuntimeState
record.AgentInstances[i].FailureReason = ""
}
agnetMu.Lock()
agnetDeployments[record.DeploymentID] = record
agnetMu.Unlock()
if err := updateAgnetDeploymentRecord(record); err != nil {
common.SysLog("AgnetSimulateUserDeploymentEvents persist: " + err.Error())
agnetError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist simulated deployment state")
return
}
for _, eventName := range events {
recordAgnetAuditEvent(agnetEvent{
EventID: "evt_" + common.GetUUID()[:12],
Event: eventName,
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: agnetNow(),
}, "agnet_simulator", record.DeploymentID, agnetRequestID(c), "simulated")
}
common.ApiSuccess(c, gin.H{
"deployment_id": record.DeploymentID,
"simulated": true,
"events": events,
"total": len(events),
})
}
func AgnetListDeploymentLogs(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
@@ -1097,6 +1315,13 @@ func AgnetListDeploymentLogs(c *gin.Context) {
})
}
func AgnetListUserDeploymentLogs(c *gin.Context) {
if _, ok := requireAuthenticatedUserAgnetDeployment(c); !ok {
return
}
AgnetListDeploymentLogs(c)
}
func AgnetGetDeploymentMetrics(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
@@ -1130,6 +1355,13 @@ func AgnetGetDeploymentMetrics(c *gin.Context) {
})
}
func AgnetGetUserDeploymentMetrics(c *gin.Context) {
if _, ok := requireAuthenticatedUserAgnetDeployment(c); !ok {
return
}
AgnetGetDeploymentMetrics(c)
}
func AgnetProjectDashboardSnapshot(c *gin.Context) {
bindingScope := strings.TrimSpace(c.Param("project_id"))
if bindingScope == "" {
@@ -177,6 +177,27 @@ func postAgnetCreateDeployment(t *testing.T, plan agnetOrchestrationPlan) (*http
return recorder, envelope
}
func postAgnetCreateUserDeployment(t *testing.T, userID int, plan agnetOrchestrationPlan) (*httptest.ResponseRecorder, agnetCreateTestEnvelope) {
t.Helper()
gin.SetMode(gin.TestMode)
body, err := common.Marshal(agnetDeploymentRequest{Plan: plan})
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set("id", userID)
ctx.Set("group", "development")
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/user/deployments", strings.NewReader(string(body)))
ctx.Request.Header.Set("Content-Type", "application/json")
AgnetCreateUserDeployment(ctx)
var envelope agnetCreateTestEnvelope
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &envelope))
return recorder, envelope
}
func TestAgnetDeploymentSurvivesInProcessStateReset(t *testing.T) {
db := setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
@@ -228,6 +249,311 @@ func TestAgnetDeploymentSurvivesInProcessStateReset(t *testing.T) {
require.Equal(t, float64(1), listData["total"])
}
func TestAgnetDeploymentPersistsSubMode(t *testing.T) {
db := setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
plan := baseAgnetResourceGrantPlan()
plan.SubMode = "waterfall"
recorder, envelope := postAgnetCreateDeployment(t, plan)
require.Equal(t, http.StatusOK, recorder.Code)
require.True(t, envelope.Success)
require.Contains(t, recorder.Body.String(), `"sub_mode":"waterfall"`)
var createBody map[string]any
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &createBody))
deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string)
var stored model.AgnetDeployment
require.NoError(t, db.Where("deployment_id = ?", deploymentID).First(&stored).Error)
require.Equal(t, "waterfall", stored.SubMode)
resetAgnetControlPlaneState(t)
getRecorder := httptest.NewRecorder()
getCtx, _ := gin.CreateTestContext(getRecorder)
getCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
getCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/deployments/"+deploymentID, nil)
AgnetGetDeployment(getCtx)
require.Equal(t, http.StatusOK, getRecorder.Code)
require.Contains(t, getRecorder.Body.String(), `"sub_mode":"waterfall"`)
}
func TestAgnetDeploymentDefaultsSubModeToAgile(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
plan := baseAgnetResourceGrantPlan()
plan.SubMode = ""
recorder, envelope := postAgnetCreateDeployment(t, plan)
require.Equal(t, http.StatusOK, recorder.Code)
require.True(t, envelope.Success)
require.Contains(t, recorder.Body.String(), `"sub_mode":"agile"`)
}
func TestAgnetCreateDeploymentRejectsInvalidSubMode(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
plan := baseAgnetResourceGrantPlan()
plan.SubMode = "scrum"
_, envelope := postAgnetCreateDeployment(t, plan)
require.False(t, envelope.Success)
require.Equal(t, "POLICY_REJECTED", envelope.Error.Code)
require.Empty(t, agnetDeployments)
}
func TestAgnetUserDeploymentForcesAuthenticatedUserScope(t *testing.T) {
db := setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
plan := baseAgnetResourceGrantPlan()
plan.UserContext.UserID = ""
plan.UserContext.ChannelID = ""
for agentIdx := range plan.Agents {
for grantIdx := range plan.Agents[agentIdx].ResourceGrants {
plan.Agents[agentIdx].ResourceGrants[grantIdx].UserID = ""
}
}
recorder, envelope := postAgnetCreateUserDeployment(t, 7, plan)
require.Equal(t, http.StatusOK, recorder.Code)
require.True(t, envelope.Success)
var createBody map[string]any
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &createBody))
deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string)
var stored model.AgnetDeployment
require.NoError(t, db.Where("deployment_id = ?", deploymentID).First(&stored).Error)
require.Equal(t, "7", stored.UserID)
require.Equal(t, "development", stored.ChannelID)
require.Contains(t, stored.PlanJSON, `"user_id":"7"`)
require.NotContains(t, stored.PlanJSON, `"user_id":"user-p1"`)
}
func TestAgnetUserDeploymentRejectsMismatchedUserScope(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
plan := baseAgnetResourceGrantPlan()
plan.UserContext.UserID = "8"
_, envelope := postAgnetCreateUserDeployment(t, 7, plan)
require.False(t, envelope.Success)
require.Equal(t, "USER_CONTEXT_FORBIDDEN", envelope.Error.Code)
require.Empty(t, agnetDeployments)
}
func TestAgnetUserDeploymentListIsScopedToAuthenticatedUser(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
user7Plan := baseAgnetResourceGrantPlan()
user7Plan.UserContext.UserID = "7"
user7Plan.Metadata.CorrelationID = "corr-user-7"
for idx := range user7Plan.Agents[0].ResourceGrants {
user7Plan.Agents[0].ResourceGrants[idx].UserID = "7"
}
_, user7Envelope := postAgnetCreateDeployment(t, user7Plan)
require.True(t, user7Envelope.Success)
user8Plan := baseAgnetResourceGrantPlan()
user8Plan.UserContext.UserID = "8"
user8Plan.Metadata.CorrelationID = "corr-user-8"
for idx := range user8Plan.Agents[0].ResourceGrants {
user8Plan.Agents[0].ResourceGrants[idx].UserID = "8"
}
_, user8Envelope := postAgnetCreateDeployment(t, user8Plan)
require.True(t, user8Envelope.Success)
listRecorder := httptest.NewRecorder()
listCtx, _ := gin.CreateTestContext(listRecorder)
listCtx.Set("id", 7)
listCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/user/deployments", nil)
AgnetListUserDeployments(listCtx)
require.Equal(t, http.StatusOK, listRecorder.Code)
var listBody map[string]any
require.NoError(t, common.Unmarshal(listRecorder.Body.Bytes(), &listBody))
require.Equal(t, true, listBody["success"])
listData := listBody["data"].(map[string]any)
require.Equal(t, float64(1), listData["total"])
items := listData["items"].([]any)
item := items[0].(map[string]any)
planData := item["orchestration_plan"].(map[string]any)
userContext := planData["user_context"].(map[string]any)
require.Equal(t, "7", userContext["user_id"])
}
func TestAgnetTaskDeploymentDraftBuildsSafePlan(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
body := `{
"task":{"id":"task_bridge_1","name":"Ship Manager sub mode","intent":"Need a scoped implementation","status":"running","card":{"goal":"Implement the Manager-side sub task flow"}},
"sub_mode":"waterfall",
"binding_scope":"repo-main",
"role_templates":["backend"],
"default_model_id":"agnet-model-builder",
"budget":{"max_tokens":10000,"max_cost_usd":2,"max_duration_sec":900}
}`
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set("id", 7)
ctx.Set("group", "development")
ctx.Params = gin.Params{{Key: "task_id", Value: "task_bridge_1"}}
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/user/tasks/task_bridge_1/deployment-draft", strings.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
AgnetCreateTaskDeploymentDraft(ctx)
require.Equal(t, http.StatusOK, recorder.Code)
require.NotContains(t, strings.ToLower(recorder.Body.String()), "password")
require.NotContains(t, strings.ToLower(recorder.Body.String()), "access_token")
var env map[string]any
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &env))
require.Equal(t, true, env["success"])
data := env["data"].(map[string]any)
plan := data["orchestration_plan"].(map[string]any)
require.Equal(t, "task_bridge_1", plan["intent_id"])
require.Equal(t, "waterfall", plan["sub_mode"])
require.Equal(t, "Implement the Manager-side sub task flow", plan["objective"])
userContext := plan["user_context"].(map[string]any)
require.Equal(t, "7", userContext["user_id"])
agents := plan["agents"].([]any)
agent := agents[0].(map[string]any)
grants := agent["resource_grants"].([]any)
grant := grants[0].(map[string]any)
require.Equal(t, "project_doc", grant["resource_type"])
require.Equal(t, "7", grant["user_id"])
}
func TestAgnetTaskDeploymentDraftRequiresTaskSnapshot(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set("id", 7)
ctx.Params = gin.Params{{Key: "task_id", Value: "task_missing"}}
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/user/tasks/task_missing/deployment-draft", strings.NewReader(`{"sub_mode":"agile"}`))
ctx.Request.Header.Set("Content-Type", "application/json")
AgnetCreateTaskDeploymentDraft(ctx)
var envelope agnetCreateTestEnvelope
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &envelope))
require.False(t, envelope.Success)
require.Equal(t, "TASK_NOT_FOUND", envelope.Error.Code)
}
func TestAgnetTaskDeploymentDraftRejectsCredentialGrantWithoutSecretRef(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
body := `{
"task":{"id":"task_bridge_git","name":"Use a git repo","intent":"Need repo access","status":"running"},
"sub_mode":"agile",
"binding_scope":"repo-main",
"role_templates":["backend"],
"default_model_id":"agnet-model-builder",
"resource_grants":[{
"resource_id":"repo-1",
"resource_type":"git",
"permission_scope":["repo:read"]
}]
}`
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set("id", 7)
ctx.Params = gin.Params{{Key: "task_id", Value: "task_bridge_git"}}
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/user/tasks/task_bridge_git/deployment-draft", strings.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
AgnetCreateTaskDeploymentDraft(ctx)
var envelope agnetCreateTestEnvelope
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &envelope))
require.False(t, envelope.Success)
require.Equal(t, "RESOURCE_GRANT_SECRET_REF_REQUIRED", envelope.Error.Code)
}
func TestAgnetUserDeploymentSimulatedEventsArePersistedAndPrefixed(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
plan := baseAgnetResourceGrantPlan()
plan.UserContext.UserID = "7"
for idx := range plan.Agents[0].ResourceGrants {
plan.Agents[0].ResourceGrants[idx].UserID = "7"
}
createRecorder, createEnvelope := postAgnetCreateUserDeployment(t, 7, plan)
require.Equal(t, http.StatusOK, createRecorder.Code)
require.True(t, createEnvelope.Success)
var createBody map[string]any
require.NoError(t, common.Unmarshal(createRecorder.Body.Bytes(), &createBody))
deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string)
simRecorder := httptest.NewRecorder()
simCtx, _ := gin.CreateTestContext(simRecorder)
simCtx.Set("id", 7)
simCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
simCtx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/user/deployments/"+deploymentID+"/simulate-events", strings.NewReader(`{"events":["deployment.started","simulation.agent.done"]}`))
simCtx.Request.Header.Set("Content-Type", "application/json")
AgnetSimulateUserDeploymentEvents(simCtx)
require.Equal(t, http.StatusOK, simRecorder.Code)
require.Contains(t, simRecorder.Body.String(), `"simulated":true`)
require.Contains(t, simRecorder.Body.String(), `"simulation.deployment.started"`)
require.Contains(t, simRecorder.Body.String(), `"simulation.agent.done"`)
resetAgnetControlPlaneState(t)
eventsRecorder := httptest.NewRecorder()
eventsCtx, _ := gin.CreateTestContext(eventsRecorder)
eventsCtx.Set("id", 7)
eventsCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
eventsCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/user/deployments/"+deploymentID+"/events", nil)
AgnetListUserDeploymentEvents(eventsCtx)
require.Equal(t, http.StatusOK, eventsRecorder.Code)
require.Contains(t, eventsRecorder.Body.String(), `"simulation.deployment.started"`)
require.Contains(t, eventsRecorder.Body.String(), `"simulation.agent.done"`)
}
func TestAgnetUserDeploymentSimulationRejectsOtherUsersDeployment(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
plan := baseAgnetResourceGrantPlan()
plan.UserContext.UserID = "7"
for idx := range plan.Agents[0].ResourceGrants {
plan.Agents[0].ResourceGrants[idx].UserID = "7"
}
createRecorder, createEnvelope := postAgnetCreateUserDeployment(t, 7, plan)
require.Equal(t, http.StatusOK, createRecorder.Code)
require.True(t, createEnvelope.Success)
var createBody map[string]any
require.NoError(t, common.Unmarshal(createRecorder.Body.Bytes(), &createBody))
deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string)
simRecorder := httptest.NewRecorder()
simCtx, _ := gin.CreateTestContext(simRecorder)
simCtx.Set("id", 8)
simCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
simCtx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/user/deployments/"+deploymentID+"/simulate-events", strings.NewReader(`{}`))
simCtx.Request.Header.Set("Content-Type", "application/json")
AgnetSimulateUserDeploymentEvents(simCtx)
var envelope agnetCreateTestEnvelope
require.NoError(t, common.Unmarshal(simRecorder.Body.Bytes(), &envelope))
require.False(t, envelope.Success)
require.Equal(t, "DEPLOYMENT_FORBIDDEN", envelope.Error.Code)
}
func TestAgnetStopDeploymentPersistsState(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
+253
View File
@@ -0,0 +1,253 @@
package controller
import (
"fmt"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
)
type agnetTaskDeploymentDraftRequest struct {
Task agnetTaskSnapshot `json:"task"`
SubMode string `json:"sub_mode"`
RiskLevel string `json:"risk_level"`
Budget agnetBudget `json:"budget"`
BindingScope string `json:"binding_scope"`
RoleTemplates []string `json:"role_templates"`
DefaultModelID string `json:"default_model_id"`
ResourceGrants []agnetResourceGrant `json:"resource_grants"`
}
type agnetTaskSnapshot struct {
ID string `json:"id"`
Name string `json:"name"`
Intent string `json:"intent"`
Status string `json:"status"`
Card map[string]any `json:"card"`
}
func stringFromTaskCard(card map[string]any, key string) string {
if card == nil {
return ""
}
if value, ok := card[key].(string); ok {
return strings.TrimSpace(value)
}
return ""
}
func objectiveFromTaskSnapshot(task agnetTaskSnapshot) string {
for _, value := range []string{
stringFromTaskCard(task.Card, "goal"),
task.Name,
task.Intent,
} {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func normalizeAgnetDraftBudget(budget agnetBudget) agnetBudget {
if budget.MaxTokens <= 0 {
budget.MaxTokens = 120000
}
if budget.MaxCostUSD <= 0 {
budget.MaxCostUSD = 8
}
if budget.MaxDurationSec <= 0 {
budget.MaxDurationSec = 3600
}
return budget
}
func normalizeAgnetDraftRoleTemplates(values []string) []string {
roles := make([]string, 0, len(values))
for _, value := range values {
role := strings.TrimSpace(value)
if role == "" {
continue
}
roles = append(roles, role)
}
if len(roles) == 0 {
return []string{"backend"}
}
return roles
}
func defaultAgnetTaskBindingScope(taskID string) string {
bindingScope := "task-" + sanitizeAgnetRef(taskID)
if bindingScope == "task-" {
return "task-local"
}
return bindingScope
}
func defaultTaskDraftResourceGrant(userID string, bindingScope string, role string, taskID string) agnetResourceGrant {
return agnetResourceGrant{
GrantID: "grant-" + sanitizeAgnetRef(taskID) + "-" + sanitizeAgnetRef(role),
ResourceID: "task-" + sanitizeAgnetRef(taskID) + "-context",
ResourceType: agnetResourceProjectDoc,
UserID: userID,
BindingScope: bindingScope,
TargetRole: role,
TargetAgentRef: "agent-" + sanitizeAgnetRef(role) + "-1",
PermissionScope: []string{"doc:read"},
Constraints: map[string]string{"ref": "task-card"},
Metadata: map[string]string{"provider": "heicode-task", "resource_ref": taskID},
Status: agnetGrantStatusActive,
Audit: map[string]string{"source": "heicode-task-draft"},
}
}
func sanitizeAgnetRef(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
var b strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-' || r == '_':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
return strings.Trim(b.String(), "-")
}
func buildAgnetDraftAgentPlan(role string, defaultModelID string, grants []agnetResourceGrant) agnetAgentPlan {
if defaultModelID == "" {
defaultModelID = "agnet-model-" + sanitizeAgnetRef(role)
}
return agnetAgentPlan{
RoleTemplate: role,
Goal: fmt.Sprintf("Execute the Heicode task as %s within the approved resource scope.", role),
DefaultModelID: defaultModelID,
ResourceGrants: grants,
}
}
func normalizeTaskDraftResourceGrants(userID string, bindingScope string, role string, taskID string, grants []agnetResourceGrant) []agnetResourceGrant {
if len(grants) == 0 {
return []agnetResourceGrant{defaultTaskDraftResourceGrant(userID, bindingScope, role, taskID)}
}
normalized := make([]agnetResourceGrant, 0, len(grants))
for idx, grant := range grants {
grant.UserID = userID
if strings.TrimSpace(grant.GrantID) == "" {
grant.GrantID = fmt.Sprintf("grant-%s-%s-%d", sanitizeAgnetRef(taskID), sanitizeAgnetRef(role), idx+1)
}
if strings.TrimSpace(grant.BindingScope) == "" {
grant.BindingScope = bindingScope
}
if strings.TrimSpace(grant.TargetRole) == "" {
grant.TargetRole = role
}
if strings.TrimSpace(grant.TargetAgentRef) == "" {
grant.TargetAgentRef = "agent-" + sanitizeAgnetRef(role) + "-1"
}
if strings.TrimSpace(grant.Status) == "" {
grant.Status = agnetGrantStatusActive
}
normalized = append(normalized, grant)
}
return normalized
}
func AgnetCreateTaskDeploymentDraft(c *gin.Context) {
taskID := strings.TrimSpace(c.Param("task_id"))
if taskID == "" {
agnetError(c, "TASK_NOT_FOUND", "task_id is required")
return
}
var req agnetTaskDeploymentDraftRequest
if err := c.ShouldBindJSON(&req); err != nil {
agnetError(c, "POLICY_REJECTED", err.Error())
return
}
if strings.TrimSpace(req.Task.ID) == "" {
agnetError(c, "TASK_NOT_FOUND", "task snapshot is required")
return
}
if strings.TrimSpace(req.Task.ID) != taskID {
agnetError(c, "TASK_CONFLICT", "task snapshot id must match route task_id")
return
}
if !isValidAgnetSubMode(req.SubMode) {
agnetError(c, "POLICY_REJECTED", "sub_mode must be agile or waterfall")
return
}
userID := strconv.Itoa(c.GetInt("id"))
if userID == "0" {
agnetError(c, "POLICY_REJECTED", "authenticated user is required")
return
}
objective := objectiveFromTaskSnapshot(req.Task)
if objective == "" {
agnetError(c, "POLICY_REJECTED", "task objective is required")
return
}
bindingScope := strings.TrimSpace(req.BindingScope)
if bindingScope == "" {
bindingScope = defaultAgnetTaskBindingScope(taskID)
}
roles := normalizeAgnetDraftRoleTemplates(req.RoleTemplates)
riskLevel := strings.TrimSpace(req.RiskLevel)
if riskLevel == "" {
riskLevel = agnetRiskLow
}
defaultModelID := strings.TrimSpace(req.DefaultModelID)
group := strings.TrimSpace(c.GetString("group"))
agents := make([]agnetAgentPlan, 0, len(roles))
runtimeAgents := make([]agnetRuntimeAgent, 0, len(roles))
for _, role := range roles {
grants := normalizeTaskDraftResourceGrants(userID, bindingScope, role, taskID, req.ResourceGrants)
agents = append(agents, buildAgnetDraftAgentPlan(role, defaultModelID, grants))
modelRef := defaultModelID
if modelRef == "" {
modelRef = "agnet-model-" + sanitizeAgnetRef(role)
}
runtimeAgents = append(runtimeAgents, agnetRuntimeAgent{Role: role, ModelRef: modelRef, InstanceCount: 1})
}
plan := agnetOrchestrationPlan{
IntentID: taskID,
TemplateHint: "heicode-task",
Objective: objective,
SubMode: normalizeAgnetSubMode(req.SubMode),
RiskLevel: riskLevel,
Budget: normalizeAgnetDraftBudget(req.Budget),
UserContext: agnetUserContext{
UserID: userID,
Role: "user",
ChannelID: group,
},
AgentRuntime: agnetAgentRuntime{Platform: "agnet", Agents: runtimeAgents},
Agents: agents,
Constraints: agnetConstraints{AllowedModelIDs: []string{}},
Metadata: agnetMetadata{
CorrelationID: "task-" + sanitizeAgnetRef(taskID) + "-" + common.GetUUID()[:8],
},
}
if group != "" {
plan.BillingContext = agnetBillingContext{Provider: "newapi", NewAPIGroup: group}
}
if !validateOrchestrationPlan(c, plan) {
return
}
common.ApiSuccess(c, gin.H{
"task_id": taskID,
"orchestration_plan": plan,
})
}
+1
View File
@@ -11,6 +11,7 @@ type AgnetDeployment struct {
ChannelID string `gorm:"type:varchar(64)" json:"channel_id"`
BindingScope string `gorm:"type:varchar(512);index" json:"binding_scope"`
CorrelationID string `gorm:"type:varchar(64);index" json:"correlation_id"`
SubMode string `gorm:"type:varchar(32);index" json:"sub_mode"`
Status string `gorm:"type:varchar(32);index" json:"status"`
Phase string `gorm:"type:varchar(32);index" json:"phase"`
RuntimeState string `gorm:"type:varchar(32)" json:"runtime_state"`
+10 -6
View File
@@ -499,6 +499,16 @@ func SetApiRouter(router *gin.Engine) {
agnetApprovalRoute.POST("/approvals/:approval_id/reject", controller.RejectAgnetApprovalRequest)
agnetApprovalRoute.GET("/credential-leases", controller.ListAgnetCredentialLeases)
agnetApprovalRoute.POST("/credential-leases/:lease_id/revoke", controller.RevokeAgnetCredentialLease)
agnetApprovalRoute.GET("/role-templates", controller.AgnetListRoleTemplates)
agnetApprovalRoute.GET("/user/deployments", controller.AgnetListUserDeployments)
agnetApprovalRoute.POST("/user/deployments", controller.AgnetCreateUserDeployment)
agnetApprovalRoute.GET("/user/deployments/:deployment_id", controller.AgnetGetUserDeployment)
agnetApprovalRoute.POST("/user/deployments/:deployment_id/stop", controller.AgnetStopUserDeployment)
agnetApprovalRoute.GET("/user/deployments/:deployment_id/logs", controller.AgnetListUserDeploymentLogs)
agnetApprovalRoute.GET("/user/deployments/:deployment_id/metrics", controller.AgnetGetUserDeploymentMetrics)
agnetApprovalRoute.GET("/user/deployments/:deployment_id/events", controller.AgnetListUserDeploymentEvents)
agnetApprovalRoute.POST("/user/deployments/:deployment_id/simulate-events", controller.AgnetSimulateUserDeploymentEvents)
agnetApprovalRoute.POST("/user/tasks/:task_id/deployment-draft", controller.AgnetCreateTaskDeploymentDraft)
}
// Agnet orchestration control plane (minimal integration endpoints)
@@ -516,12 +526,6 @@ func SetApiRouter(router *gin.Engine) {
agnetRoute.POST("/sk-snapshots/resolve", controller.AgnetResolveSKSnapshots)
agnetRoute.GET("/projects/:project_id/dashboard-snapshot", controller.AgnetProjectDashboardSnapshot)
agnetRoute.GET("/audit-logs", controller.AgnetListAuditLogs)
// Platform-recommended role catalog. Six canonical Agnet
// roles per docs/product-package/13-platform-description.md
// §3 (Product / Architect / Frontend / Backend / Reviewer /
// Ops). UI uses this to populate the deployment-creation
// role picker. Read-only, no secrets in payload.
agnetRoute.GET("/role-templates", controller.AgnetListRoleTemplates)
}
}
}
+26 -4
View File
@@ -1,5 +1,7 @@
import { api } from '@/lib/api'
export type AgnetSubMode = 'agile' | 'waterfall'
/** Sub-agent cloud/runtime binding (passed to Agnet on deploy). */
export type AgnetRuntimeExecution = {
profile_id?: string
@@ -107,6 +109,7 @@ export type AgnetOrchestrationPlan = {
intent_id: string
template_hint: string
objective: string
sub_mode?: AgnetSubMode
risk_level: 'low' | 'medium' | 'high'
budget: AgnetBudget
user_context: AgnetUserContext
@@ -123,6 +126,7 @@ export type AgnetCreateDeploymentBody = {
export type AgnetCreateDeploymentResult = {
deployment_id: string
sub_mode?: AgnetSubMode
status: string
agent_instances?: Array<{
instance_id?: string
@@ -151,6 +155,7 @@ export type AgnetPermissionManifest = {
export type AgnetDeployment = {
deployment_id: string
sub_mode?: AgnetSubMode
status: string
phase: string
runtime_state?: string
@@ -162,6 +167,7 @@ export type AgnetDeployment = {
intent_id?: string
template_hint?: string
objective?: string
sub_mode?: AgnetSubMode
risk_level?: string
budget?: AgnetBudget
agents?: AgnetAgentPlan[]
@@ -276,14 +282,14 @@ export async function listAgnetRoleTemplates(): Promise<AgnetRoleTemplate[]> {
export async function listAgnetDeployments(): Promise<AgnetDeployment[]> {
const res = await api.get<ApiEnvelope<{ items?: AgnetDeployment[] }>>(
'/api/agnet/deployments'
'/api/agnet/user/deployments'
)
return res.data?.data?.items ?? []
}
export async function listAgnetDeploymentsQuiet(): Promise<AgnetDeployment[]> {
const res = await api.get<ApiEnvelope<{ items?: AgnetDeployment[] }>>(
'/api/agnet/deployments',
'/api/agnet/user/deployments',
{
skipBusinessError: true,
skipErrorHandler: true,
@@ -297,7 +303,7 @@ export async function createAgnetDeployment(
body: AgnetCreateDeploymentBody
): Promise<AgnetCreateDeploymentResult> {
const res = await api.post<ApiEnvelope<AgnetCreateDeploymentResult>>(
'/api/agnet/deployments',
'/api/agnet/user/deployments',
body
)
const env = res.data
@@ -314,10 +320,26 @@ export async function createAgnetDeployment(
export async function getAgnetDeploymentEvents(deploymentId: string) {
const res = await api.get<
ApiEnvelope<{ items?: Array<Record<string, unknown>> }>
>(`/api/agnet/deployments/${deploymentId}/events`)
>(`/api/agnet/user/deployments/${deploymentId}/events`)
return res.data?.data?.items ?? []
}
export async function simulateAgnetDeploymentEvents(
deploymentId: string,
events?: string[]
): Promise<{ deployment_id: string; simulated: boolean; total: number }> {
const res = await api.post<
ApiEnvelope<{ deployment_id: string; simulated: boolean; total: number }>
>(`/api/agnet/user/deployments/${deploymentId}/simulate-events`, {
events: events ?? [],
})
const env = res.data
if (!env?.success || !env.data) {
throw new Error(env?.message || 'simulateAgnetDeploymentEvents failed')
}
return env.data
}
export async function getAgnetAuditLogs() {
const res = await api.get<
ApiEnvelope<{ items?: Array<Record<string, unknown>> }>
@@ -1,7 +1,5 @@
import { useEffect, useMemo, useState, type ComponentType } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
Bot,
CheckCircle2,
@@ -15,6 +13,10 @@ import {
Sparkles,
Trash2,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth-store'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
@@ -37,8 +39,6 @@ import {
} from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
import {
createAgnetDeployment,
listAgnetRoleTemplates,
@@ -46,6 +46,7 @@ import {
type AgnetOrchestrationPlan,
type AgnetRoleTemplate,
type AgnetSKSource,
type AgnetSubMode,
} from './api'
type ResourceType =
@@ -82,7 +83,9 @@ type TemplatePreset = {
label: string
objective: string
risk: 'low' | 'medium' | 'high'
roles: Array<Pick<AgentFormRow, 'role_template' | 'goal' | 'default_model_id'>>
roles: Array<
Pick<AgentFormRow, 'role_template' | 'goal' | 'default_model_id'>
>
}
const RESOURCE_TYPES: ResourceType[] = [
@@ -338,10 +341,10 @@ function SectionTitle({
}) {
return (
<div className='flex items-start gap-2'>
<Icon className='mt-0.5 h-4 w-4 text-primary' />
<Icon className='text-primary mt-0.5 h-4 w-4' />
<div>
<p className='text-sm font-semibold'>{title}</p>
{hint ? <p className='text-xs text-muted-foreground'>{hint}</p> : null}
{hint ? <p className='text-muted-foreground text-xs'>{hint}</p> : null}
</div>
</div>
)
@@ -362,6 +365,7 @@ export function CreateAgnetDeploymentSheet({
const [step, setStep] = useState('idea')
const [templateHint, setTemplateHint] = useState(defaultTemplate.id)
const [objective, setObjective] = useState(defaultTemplate.objective)
const [subMode, setSubMode] = useState<AgnetSubMode>('agile')
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high'>(
defaultTemplate.risk
)
@@ -410,6 +414,7 @@ export function CreateAgnetDeploymentSheet({
setStep('idea')
setTemplateHint(defaultTemplate.id)
setObjective(defaultTemplate.objective)
setSubMode('agile')
setRiskLevel(defaultTemplate.risk)
setMaxTokens(120_000)
setMaxCost(8)
@@ -493,6 +498,7 @@ export function CreateAgnetDeploymentSheet({
intent_id: '<generated on submit>',
template_hint: templateHint.trim(),
objective: objective.trim(),
sub_mode: subMode,
risk_level: riskLevel,
resource_scope_ref: resourceScopeRef.trim(),
agent_runtime: {
@@ -522,6 +528,7 @@ export function CreateAgnetDeploymentSheet({
objective,
resourceScopeRef,
riskLevel,
subMode,
templateHint,
userId,
])
@@ -591,6 +598,7 @@ export function CreateAgnetDeploymentSheet({
intent_id: intentId,
template_hint: templateHint.trim(),
objective: objective.trim(),
sub_mode: subMode,
risk_level: riskLevel,
budget: {
max_tokens: maxTokens,
@@ -727,10 +735,10 @@ export function CreateAgnetDeploymentSheet({
<div className='flex items-center justify-between gap-2'>
<p className='text-sm font-medium'>{template.label}</p>
{templateHint === template.id ? (
<CheckCircle2 className='h-4 w-4 text-primary' />
<CheckCircle2 className='text-primary h-4 w-4' />
) : null}
</div>
<p className='mt-2 line-clamp-3 text-xs text-muted-foreground'>
<p className='text-muted-foreground mt-2 line-clamp-3 text-xs'>
{template.objective}
</p>
<Badge variant='outline' className='mt-3 text-[10px]'>
@@ -751,7 +759,7 @@ export function CreateAgnetDeploymentSheet({
placeholder={t('Describe what this work should achieve.')}
/>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='grid gap-3 sm:grid-cols-3'>
<div className='grid gap-2'>
<Label>{t('Template hint')}</Label>
<Input
@@ -778,6 +786,21 @@ export function CreateAgnetDeploymentSheet({
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label>{t('Sub mode')}</Label>
<Select
value={subMode}
onValueChange={(v) => setSubMode(v as AgnetSubMode)}
>
<SelectTrigger className='h-9'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='agile'>agile</SelectItem>
<SelectItem value='waterfall'>waterfall</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</TabsContent>
@@ -905,7 +928,7 @@ export function CreateAgnetDeploymentSheet({
)
}}
>
<Trash2 className='h-4 w-4 text-destructive' />
<Trash2 className='text-destructive h-4 w-4' />
</Button>
) : null}
</div>
@@ -979,8 +1002,8 @@ export function CreateAgnetDeploymentSheet({
className='text-sm'
/>
<div className='grid gap-2 rounded-md bg-muted/35 p-3'>
<div className='flex items-center gap-2 text-xs font-semibold text-muted-foreground'>
<div className='bg-muted/35 grid gap-2 rounded-md p-3'>
<div className='text-muted-foreground flex items-center gap-2 text-xs font-semibold'>
<GitBranch className='h-3.5 w-3.5' />
{t('Resource grant')}
</div>
@@ -1099,8 +1122,8 @@ export function CreateAgnetDeploymentSheet({
</div>
</div>
<div className='grid gap-2 rounded-md bg-muted/35 p-3'>
<div className='flex items-center gap-2 text-xs font-semibold text-muted-foreground'>
<div className='bg-muted/35 grid gap-2 rounded-md p-3'>
<div className='text-muted-foreground flex items-center gap-2 text-xs font-semibold'>
<KeyRound className='h-3.5 w-3.5' />
{t('Runtime binding')}
</div>
@@ -1128,9 +1151,9 @@ export function CreateAgnetDeploymentSheet({
</div>
</div>
<div className='grid gap-2 rounded-md bg-muted/35 p-3'>
<div className='bg-muted/35 grid gap-2 rounded-md p-3'>
<div className='flex items-center justify-between gap-2'>
<div className='flex items-center gap-2 text-xs font-semibold text-muted-foreground'>
<div className='text-muted-foreground flex items-center gap-2 text-xs font-semibold'>
<FileText className='h-3.5 w-3.5' />
{t('SK access policy')}
</div>
@@ -1209,14 +1232,16 @@ export function CreateAgnetDeploymentSheet({
always matches what gets submitted. */}
<div className='space-y-3'>
{/* 1. 本次会做 — Objective + role list */}
<div className='rounded-lg border bg-card/40 p-3'>
<p className='text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground'>
<div className='bg-card/40 rounded-lg border p-3'>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.12em] uppercase'>
{t('This run will do')}
</p>
<p className='mt-2 text-sm text-foreground'>
<p className='text-foreground mt-2 text-sm'>
{objective.trim() || (
<span className='text-muted-foreground italic'>
{t('No objective provided yet — go back to the Idea tab.')}
{t(
'No objective provided yet — go back to the Idea tab.'
)}
</span>
)}
</p>
@@ -1225,7 +1250,7 @@ export function CreateAgnetDeploymentSheet({
{previewPlan.agent_runtime.agents.map((a) => (
<span
key={a.role}
className='inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklch,var(--primary)_12%,transparent)] px-1.5 py-0.5 font-mono text-[10px] text-primary'
className='text-primary inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklch,var(--primary)_12%,transparent)] px-1.5 py-0.5 font-mono text-[10px]'
>
{a.role}
<span className='text-muted-foreground'>·</span>
@@ -1237,13 +1262,16 @@ export function CreateAgnetDeploymentSheet({
</div>
{/* 2. 本次允许使用 — Resources from permission_manifest */}
<div className='rounded-lg border bg-card/40 p-3'>
<p className='text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground'>
<div className='bg-card/40 rounded-lg border p-3'>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.12em] uppercase'>
{t('Resources this run may use')}
</p>
{previewPlan.permission_manifest.resource_grants.length === 0 ? (
<p className='mt-2 text-sm text-muted-foreground italic'>
{t('No resources bound yet — Agnet will run with no external data access.')}
{previewPlan.permission_manifest.resource_grants.length ===
0 ? (
<p className='text-muted-foreground mt-2 text-sm italic'>
{t(
'No resources bound yet — Agnet will run with no external data access.'
)}
</p>
) : (
<ul className='mt-2 space-y-1 text-xs'>
@@ -1256,12 +1284,13 @@ export function CreateAgnetDeploymentSheet({
}
className='flex items-center gap-2'
>
<span className='inline-flex items-center rounded bg-[color-mix(in_oklch,var(--primary)_10%,transparent)] px-1 font-mono text-[10px] text-primary'>
{(g as { resource_type?: string }).resource_type ||
'?'}
<span className='text-primary inline-flex items-center rounded bg-[color-mix(in_oklch,var(--primary)_10%,transparent)] px-1 font-mono text-[10px]'>
{(g as { resource_type?: string })
.resource_type || '?'}
</span>
<span className='font-mono text-[11px] text-muted-foreground'>
{(g as { resource_id?: string }).resource_id || '—'}
<span className='text-muted-foreground font-mono text-[11px]'>
{(g as { resource_id?: string }).resource_id ||
'—'}
</span>
<span className='text-muted-foreground'>·</span>
<span className='text-[11px]'>
@@ -1269,10 +1298,14 @@ export function CreateAgnetDeploymentSheet({
(g as { permission_scope?: string[] })
.permission_scope
) &&
((g as { permission_scope?: string[] })
.permission_scope as string[]).length > 0
? ((g as { permission_scope?: string[] })
.permission_scope as string[]).join(', ')
(
(g as { permission_scope?: string[] })
.permission_scope as string[]
).length > 0
? (
(g as { permission_scope?: string[] })
.permission_scope as string[]
).join(', ')
: t('no actions specified')}
</span>
</li>
@@ -1284,10 +1317,10 @@ export function CreateAgnetDeploymentSheet({
{/* 3. 本次不会做 — Static off-limits list per §10 */}
<div className='rounded-lg border border-dashed border-rose-500/30 bg-rose-500/5 p-3'>
<p className='text-[11px] font-semibold uppercase tracking-[0.12em] text-rose-400'>
<p className='text-[11px] font-semibold tracking-[0.12em] text-rose-400 uppercase'>
{t('This run will NOT do')}
</p>
<ul className='mt-2 grid gap-1 text-xs text-muted-foreground sm:grid-cols-2'>
<ul className='text-muted-foreground mt-2 grid gap-1 text-xs sm:grid-cols-2'>
<li>· {t('Production deploys without client approval')}</li>
<li>· {t('Production database writes')}</li>
<li>· {t('Long-lived credential extraction')}</li>
@@ -1296,11 +1329,11 @@ export function CreateAgnetDeploymentSheet({
</div>
{/* 4. 高危规则 — Risk level + escalation policy */}
<div className='rounded-lg border bg-card/40 p-3'>
<p className='text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground'>
<div className='bg-card/40 rounded-lg border p-3'>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.12em] uppercase'>
{t('High-risk operations')}
</p>
<p className='mt-2 text-xs text-foreground'>
<p className='text-foreground mt-2 text-xs'>
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ring-inset',
@@ -1313,8 +1346,11 @@ export function CreateAgnetDeploymentSheet({
>
{t('Risk level')}: {riskLevel}
</span>
<span className='text-primary ring-primary/20 ml-2 inline-flex items-center rounded-full bg-[color-mix(in_oklch,var(--primary)_10%,transparent)] px-2 py-0.5 font-mono text-[10px] ring-1 ring-inset'>
sub_mode: {subMode}
</span>
</p>
<p className='mt-2 text-xs leading-relaxed text-muted-foreground'>
<p className='text-muted-foreground mt-2 text-xs leading-relaxed'>
{t(
'Any high-risk action (production deploy, secret access, destructive change) requires explicit approval from the desktop client. Short-lived, scoped credentials are issued only at approval time and recorded in audit.'
)}
@@ -1322,20 +1358,20 @@ export function CreateAgnetDeploymentSheet({
</div>
{/* 5. 预计消耗 — Budget triple */}
<div className='rounded-lg border bg-card/40 p-3'>
<p className='text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground'>
<div className='bg-card/40 rounded-lg border p-3'>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.12em] uppercase'>
{t('Expected consumption')}
</p>
<div className='mt-2 grid gap-2 text-xs sm:grid-cols-3'>
<div>
<p className='text-muted-foreground'>{t('Max tokens')}</p>
<p className='font-mono text-sm text-foreground'>
<p className='text-foreground font-mono text-sm'>
{maxTokens.toLocaleString()}
</p>
</div>
<div>
<p className='text-muted-foreground'>{t('Max cost')}</p>
<p className='font-mono text-sm text-foreground'>
<p className='text-foreground font-mono text-sm'>
${maxCost}
</p>
</div>
@@ -1343,7 +1379,7 @@ export function CreateAgnetDeploymentSheet({
<p className='text-muted-foreground'>
{t('Max duration')}
</p>
<p className='font-mono text-sm text-foreground'>
<p className='text-foreground font-mono text-sm'>
{Math.round(maxDurationSec / 60)} {t('min')}
</p>
</div>
@@ -1353,11 +1389,11 @@ export function CreateAgnetDeploymentSheet({
{/* Power-user disclosure: raw payload. Folded by default so
the structured five-section card stays the primary view. */}
<details className='rounded-lg border bg-muted/20 p-2 text-xs'>
<summary className='cursor-pointer select-none text-muted-foreground'>
<details className='bg-muted/20 rounded-lg border p-2 text-xs'>
<summary className='text-muted-foreground cursor-pointer select-none'>
{t('Show raw payload (advanced)')}
</summary>
<pre className='mt-2 max-h-[300px] overflow-auto rounded bg-background/60 p-3 font-mono text-[10px] leading-relaxed'>
<pre className='bg-background/60 mt-2 max-h-[300px] overflow-auto rounded p-3 font-mono text-[10px] leading-relaxed'>
{JSON.stringify(previewPlan, null, 2)}
</pre>
</details>
+59 -10
View File
@@ -66,6 +66,7 @@ import {
listAgnetDeployments,
rejectAgnetApproval,
revokeAgnetCredentialLease,
simulateAgnetDeploymentEvents,
type AgnetApprovalRequest,
type AgnetCredentialLease,
type AgnetDeployment,
@@ -234,6 +235,10 @@ function describeRiskLevel(dep: AgnetDeployment): {
return { label: 'low', tone: 'low' }
}
function describeSubMode(dep: AgnetDeployment): string {
return dep.sub_mode || dep.orchestration_plan?.sub_mode || 'agile'
}
function describeBudget(dep: AgnetDeployment): string {
const budget = dep.orchestration_plan?.budget
if (!budget) {
@@ -342,9 +347,25 @@ function grantStatusToneClass(status: string | undefined): string {
function RunDetailPanel({ dep }: { dep: AgnetDeployment }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const phase = dep.phase || dep.status
const risk = describeRiskLevel(dep)
const grants = collectResourceGrants(dep)
const simulateMutation = useMutation({
mutationFn: () => simulateAgnetDeploymentEvents(dep.deployment_id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['agnet', 'deployments'] })
void queryClient.invalidateQueries({
queryKey: ['agnet', 'deployment-events', dep.deployment_id],
})
toast.success(t('Simulated events recorded'))
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t('Failed to simulate events')
)
},
})
// M5 — permission manifest 折叠预览. Default folded per product docs
// §10 "高级用户可以展开 manifest 预览,但默认折叠". Visible cell
// count keeps the page calm; the toggle reveals the per-grant table.
@@ -366,11 +387,29 @@ function RunDetailPanel({ dep }: { dep: AgnetDeployment }) {
t('No objective')}
</p>
</div>
<div className='flex shrink-0 items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
className='h-8 gap-1 rounded-xl text-xs'
disabled={simulateMutation.isPending}
onClick={() => simulateMutation.mutate()}
>
<Rocket className='h-3.5 w-3.5' />
{t('Simulate')}
</Button>
<StatusBadge phase={phase} />
</div>
</div>
<div className='mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-5'>
<div className='mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-6'>
<MetaPill icon={Activity} label={t('status')} value={phase || '—'} />
<MetaPill
icon={Rocket}
label={t('mode')}
value={describeSubMode(dep)}
/>
<MetaPill icon={Tag} label={t('risk')} value={risk.label} />
<MetaPill
icon={Coins}
@@ -1202,7 +1241,7 @@ function AgnetApprovalCard({
onReject: () => void
}) {
return (
<article className='rounded-xl border border-border/70 bg-background/45 p-3'>
<article className='border-border/70 bg-background/45 rounded-xl border p-3'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='font-mono text-xs font-semibold break-all'>
@@ -1255,7 +1294,7 @@ function AgnetLeaseCard({
onRevoke: () => void
}) {
return (
<article className='rounded-xl border border-border/70 bg-background/45 p-3'>
<article className='border-border/70 bg-background/45 rounded-xl border p-3'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='font-mono text-xs font-semibold break-all'>
@@ -1304,7 +1343,9 @@ export function AgnetAuditPage() {
void queryClient.invalidateQueries({
queryKey: ['agnet', 'credential-leases'],
})
void queryClient.invalidateQueries({ queryKey: ['heicode', 'agnet', 'audit'] })
void queryClient.invalidateQueries({
queryKey: ['heicode', 'agnet', 'audit'],
})
}
const approveMutation = useMutation({
@@ -1396,12 +1437,16 @@ export function AgnetAuditPage() {
)}
</p>
<section className='grid gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]'>
<div className='rounded-xl border border-border/70 bg-card/70 p-4'>
<div className='border-border/70 bg-card/70 rounded-xl border p-4'>
<div className='mb-3 flex items-center justify-between gap-3'>
<div>
<h3 className='text-sm font-semibold'>{t('Pending approvals')}</h3>
<h3 className='text-sm font-semibold'>
{t('Pending approvals')}
</h3>
<p className='text-muted-foreground text-xs'>
{t('Approve or reject high-risk Agnet operations before credentials are leased.')}
{t(
'Approve or reject high-risk Agnet operations before credentials are leased.'
)}
</p>
</div>
<StatusBadge phase='pending' />
@@ -1429,12 +1474,16 @@ export function AgnetAuditPage() {
</div>
</QueryState>
</div>
<div className='rounded-xl border border-border/70 bg-card/70 p-4'>
<div className='border-border/70 bg-card/70 rounded-xl border p-4'>
<div className='mb-3 flex items-center justify-between gap-3'>
<div>
<h3 className='text-sm font-semibold'>{t('Active credential leases')}</h3>
<h3 className='text-sm font-semibold'>
{t('Active credential leases')}
</h3>
<p className='text-muted-foreground text-xs'>
{t('Only short-lived lease references are shown. Revoke after the task ends.')}
{t(
'Only short-lived lease references are shown. Revoke after the task ends.'
)}
</p>
</div>
<ShieldCheck className='text-primary size-4' />
+140 -42
View File
@@ -11,10 +11,8 @@
* Forbidden per §10 高级展开: no JSON editor, no permission manifest, no
* resource_grant editor. Only the user-facing summary fields.
*/
import { Link, getRouteApi } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Link, getRouteApi } from '@tanstack/react-router'
import {
ArrowLeft,
ArrowRight,
@@ -32,17 +30,24 @@ import {
Wallet,
XCircle,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
answerHeicodeTask,
createDeploymentDraftFromHeicodeTask,
getHeicodeTask,
type HeicodeFollowup,
type HeicodeManagerAction,
type HeicodeTask,
type HeicodeTaskStatus,
} from '@/lib/heicode-mcp'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
createAgnetDeployment,
type AgnetOrchestrationPlan,
} from '@/features/agnet-console/api'
const route = getRouteApi('/_authenticated/tasks/$id')
@@ -86,7 +91,7 @@ function StatusBadge({ status }: { status: HeicodeTaskStatus | string }) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] ring-1 ring-inset',
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-[0.12em] uppercase ring-1 ring-inset',
p.cls
)}
>
@@ -99,7 +104,8 @@ function StatusBadge({ status }: { status: HeicodeTaskStatus | string }) {
function readScopeArray(card: HeicodeTask['card'], key: string): string[] {
if (!card) return []
const v = (card as Record<string, unknown>)[key]
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string')
if (Array.isArray(v))
return v.filter((x): x is string => typeof x === 'string')
if (typeof v === 'string' && v.trim()) return [v]
return []
}
@@ -109,7 +115,8 @@ function readManagerActions(card: HeicodeTask['card']): HeicodeManagerAction[] {
const v = (card as Record<string, unknown>).manager_actions
if (!Array.isArray(v)) return []
return v
.filter((x): x is HeicodeManagerAction =>
.filter(
(x): x is HeicodeManagerAction =>
typeof x === 'object' &&
x != null &&
typeof (x as Record<string, unknown>).label === 'string' &&
@@ -121,7 +128,12 @@ function readManagerActions(card: HeicodeTask['card']): HeicodeManagerAction[] {
/** Resolve helper icon for a manager action by deeplink keyword. */
function iconForAction(deeplink: string): typeof GitBranch {
const k = deeplink.toLowerCase()
if (k.includes('resource') || k.includes('preparation') || k.includes('sk-source')) return GitBranch
if (
k.includes('resource') ||
k.includes('preparation') ||
k.includes('sk-source')
)
return GitBranch
if (k.includes('audit')) return ShieldCheck
if (k.includes('wallet') || k.includes('budget')) return Wallet
if (k.includes('event') || k.includes('activity')) return ScrollText
@@ -143,7 +155,11 @@ function normalizeDeeplink(deeplink: string): string {
function collectOpenFollowups(task: HeicodeTask): HeicodeFollowup[] {
for (let i = task.thread.length - 1; i >= 0; i--) {
const entry = task.thread[i]!
if (entry.kind === 'heicode' && entry.followups && entry.followups.length > 0) {
if (
entry.kind === 'heicode' &&
entry.followups &&
entry.followups.length > 0
) {
return entry.followups
}
}
@@ -171,13 +187,50 @@ export function TaskCardView() {
answerHeicodeTask(id, qid, oid),
onSuccess: (updated) => {
queryClient.setQueryData(['heicode', 'task', id], updated)
void queryClient.invalidateQueries({ queryKey: ['heicode', 'tasks', 'recent'] })
void queryClient.invalidateQueries({
queryKey: ['heicode', 'tasks', 'recent'],
})
if (updated.status === 'running') {
toast.success(t('All follow-ups answered. Heicode generated the recommendation summary.'))
toast.success(
t(
'All follow-ups answered. Heicode generated the recommendation summary.'
)
)
}
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('Failed to answer follow-up'))
toast.error(
err instanceof Error ? err.message : t('Failed to answer follow-up')
)
},
})
const deploymentMutation = useMutation({
mutationFn: async () => {
if (!task) throw new Error('Task not found')
const draft = await createDeploymentDraftFromHeicodeTask(task, {
sub_mode: 'agile',
binding_scope: `task-${task.id}`,
role_templates: ['backend'],
default_model_id: 'agnet-model-builder',
})
return createAgnetDeployment({
orchestration_plan:
draft.orchestration_plan as unknown as AgnetOrchestrationPlan,
})
},
onSuccess: (deployment) => {
void queryClient.invalidateQueries({ queryKey: ['agnet', 'deployments'] })
toast.success(
t('Manager deployment created', {
deployment_id: deployment.deployment_id,
}) as string
)
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t('Deployment request failed')
)
},
})
@@ -201,8 +254,10 @@ export function TaskCardView() {
</Link>
</Button>
<div className='rounded-2xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-10 text-center'>
<p className='text-sm text-muted-foreground'>
{t('Task not found. It may have been removed or was never created.')}
<p className='text-muted-foreground text-sm'>
{t(
'Task not found. It may have been removed or was never created.'
)}
</p>
</div>
</div>
@@ -214,7 +269,8 @@ export function TaskCardView() {
// { goal: string, scope: string[], generated_artifacts: string[],
// manager_actions: Array<{label, deeplink}> }
const objective =
(task.card && (task.card as Record<string, unknown>).goal as string | undefined) ||
(task.card &&
((task.card as Record<string, unknown>).goal as string | undefined)) ||
task.name ||
task.intent ||
t('No objective')
@@ -246,7 +302,7 @@ export function TaskCardView() {
{t('Back to home')}
</Link>
</Button>
<span className='font-mono text-[11px] text-muted-foreground'>
<span className='text-muted-foreground font-mono text-[11px]'>
{task.id}
</span>
</header>
@@ -269,11 +325,14 @@ export function TaskCardView() {
<MessageSquare className='h-4 w-4' />
</span>
<div className='min-w-0 flex-1'>
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.16em] uppercase'>
{t('Heicode is asking')}
</p>
<p className='mt-1 text-sm text-foreground'>
{task.status_caption || t('Answer a few questions so Heicode can draft the right plan.')}
<p className='text-foreground mt-1 text-sm'>
{task.status_caption ||
t(
'Answer a few questions so Heicode can draft the right plan.'
)}
</p>
</div>
</div>
@@ -282,7 +341,7 @@ export function TaskCardView() {
{openFollowups.map((q) => (
<li
key={q.id}
className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-4'
className='bg-background/40 rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] p-4'
>
<p className='text-sm font-medium'>{q.question}</p>
<div className='mt-3 flex flex-wrap gap-2'>
@@ -293,7 +352,9 @@ export function TaskCardView() {
size='sm'
variant='outline'
disabled={answerMutation.isPending}
onClick={() => answerMutation.mutate({ qid: q.id, oid: opt.id })}
onClick={() =>
answerMutation.mutate({ qid: q.id, oid: opt.id })
}
className={cn(
'rounded-xl text-xs',
opt.risk === 'high-risk' &&
@@ -302,7 +363,7 @@ export function TaskCardView() {
>
{opt.label}
{opt.risk === 'high-risk' && (
<span className='ms-1 rounded-full bg-rose-500/15 px-1.5 text-[9px] uppercase tracking-wider text-rose-300'>
<span className='ms-1 rounded-full bg-rose-500/15 px-1.5 text-[9px] tracking-wider text-rose-300 uppercase'>
{t('high-risk')}
</span>
)}
@@ -326,44 +387,46 @@ export function TaskCardView() {
>
<div className='flex items-start justify-between gap-4'>
<div className='min-w-0 flex-1'>
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.16em] uppercase'>
{t('Task card')}
</p>
<h1 className='mt-2 text-2xl font-semibold tracking-tight sm:text-3xl'>
{objective}
</h1>
{task.intent && task.intent !== objective && (
<p className='mt-2 text-sm text-muted-foreground'>{task.intent}</p>
<p className='text-muted-foreground mt-2 text-sm'>
{task.intent}
</p>
)}
</div>
<StatusBadge status={task.status} />
</div>
<div className='mt-6 grid gap-5 md:grid-cols-2'>
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-4'>
<div className='bg-background/40 rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] p-4'>
<p className='flex items-center gap-1.5 text-sm font-semibold'>
<ListChecks className='h-4 w-4 text-primary' />
<ListChecks className='text-primary h-4 w-4' />
{t('First-version scope')}
</p>
<ul className='mt-2 space-y-1.5 text-sm text-muted-foreground'>
<ul className='text-muted-foreground mt-2 space-y-1.5 text-sm'>
{firstVersionScope.map((line, i) => (
<li key={i} className='flex items-start gap-2'>
<span className='mt-1.5 inline-block h-1 w-1 shrink-0 rounded-full bg-primary' />
<span className='bg-primary mt-1.5 inline-block h-1 w-1 shrink-0 rounded-full' />
<span>{line}</span>
</li>
))}
</ul>
</div>
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40 p-4'>
<div className='bg-background/40 rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] p-4'>
<p className='flex items-center gap-1.5 text-sm font-semibold'>
<Sparkles className='h-4 w-4 text-primary' />
<Sparkles className='text-primary h-4 w-4' />
{t('Heicode auto-generates')}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
<p className='text-muted-foreground mt-2 text-sm'>
{autoGenerated.join(' / ')}
</p>
<p className='mt-2 text-[11px] text-muted-foreground'>
<p className='text-muted-foreground mt-2 text-[11px]'>
{t(
'These artifacts appear inside the desktop client as the task progresses.'
)}
@@ -371,8 +434,8 @@ export function TaskCardView() {
</div>
</div>
<div className='mt-5 rounded-2xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-background/30 p-4'>
<p className='text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase'>
<div className='bg-background/30 mt-5 rounded-2xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-4'>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.14em] uppercase'>
{t('Needs Manager assistance')}
</p>
<div className='mt-3 flex flex-wrap gap-2'>
@@ -397,19 +460,34 @@ export function TaskCardView() {
})
) : (
<>
<Button asChild size='sm' variant='outline' className='rounded-xl'>
<Button
asChild
size='sm'
variant='outline'
className='rounded-xl'
>
<Link to='/sk-sources'>
<GitBranch className='mr-1 h-3.5 w-3.5' />
{t('Open preparation checklist')}
</Link>
</Button>
<Button asChild size='sm' variant='outline' className='rounded-xl'>
<Button
asChild
size='sm'
variant='outline'
className='rounded-xl'
>
<Link to='/audit'>
<ShieldCheck className='mr-1 h-3.5 w-3.5' />
{t('Audit & approvals')}
</Link>
</Button>
<Button asChild size='sm' variant='outline' className='rounded-xl'>
<Button
asChild
size='sm'
variant='outline'
className='rounded-xl'
>
<Link to='/wallet'>
<Wallet className='mr-1 h-3.5 w-3.5' />
{t('Budget & usage')}
@@ -424,13 +502,31 @@ export function TaskCardView() {
<Button
variant='ghost'
size='sm'
className='gap-1 text-muted-foreground'
className='text-muted-foreground gap-1'
disabled
title={t('Editing the objective happens in the desktop client.')}
>
<PencilLine className='h-3.5 w-3.5' />
{t('Edit objective in desktop client')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
className='gap-1 rounded-xl'
disabled={
deploymentMutation.isPending ||
task.status === 'failed' ||
task.status === 'draft' ||
task.status === 'configuring'
}
onClick={() => deploymentMutation.mutate()}
>
<Rocket className='h-3.5 w-3.5' />
{deploymentMutation.isPending
? t('Creating deployment')
: t('Create Manager deployment')}
</Button>
<Button
asChild
size='sm'
@@ -450,10 +546,12 @@ export function TaskCardView() {
</section>
<section className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4'>
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.16em] uppercase'>
{t('Pending context')}
</p>
<p className='mt-2 text-sm text-muted-foreground'>{pendingContextLine}</p>
<p className='text-muted-foreground mt-2 text-sm'>
{pendingContextLine}
</p>
</section>
</div>
)
+153 -43
View File
@@ -1,3 +1,5 @@
import { api } from '@/lib/api'
/**
* mcp-server (Heicode Manager backend at apimtaiji.azure-api.net/api/mcp)
* typed client.
@@ -34,10 +36,7 @@ function readToken(): string {
return window.localStorage.getItem(ACCESS_TOKEN_KEY) || ''
}
async function mcpFetch<T>(
path: string,
init: RequestInit = {}
): Promise<T> {
async function mcpFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = readToken()
const requestId = `heicode-mcp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
const res = await fetch(`${MCP_BASE}${path}`, {
@@ -59,9 +58,9 @@ async function mcpFetch<T>(
data = null
}
if (!res.ok) {
const detail =
(data as { detail?: string | { message?: string; code?: string } } | null)
?.detail
const detail = (
data as { detail?: string | { message?: string; code?: string } } | null
)?.detail
const message =
typeof detail === 'string'
? detail
@@ -136,7 +135,27 @@ export type HeicodeTask = {
type Envelope<T> = { success: boolean; data?: T; message?: string }
export async function createTaskFromIntent(intent: string, name?: string): Promise<HeicodeTask> {
export type HeicodeTaskDeploymentDraftOptions = {
sub_mode?: 'agile' | 'waterfall'
binding_scope?: string
role_templates?: string[]
default_model_id?: string
budget?: {
max_tokens: number
max_cost_usd: number
max_duration_sec: number
}
}
export type HeicodeTaskDeploymentDraft = {
task_id: string
orchestration_plan: Record<string, unknown>
}
export async function createTaskFromIntent(
intent: string,
name?: string
): Promise<HeicodeTask> {
const env = await mcpFetch<Envelope<HeicodeTask>>('/api/user/tasks/intent', {
method: 'POST',
body: JSON.stringify({ intent, ...(name ? { name } : {}) }),
@@ -151,21 +170,33 @@ export async function listHeicodeTasks(params?: {
status?: HeicodeTaskStatus
limit?: number
offset?: number
}): Promise<{ items: HeicodeTask[]; total: number; offset: number; limit: number }> {
}): Promise<{
items: HeicodeTask[]
total: number
offset: number
limit: number
}> {
const qs = new URLSearchParams()
if (params?.status) qs.set('status', params.status)
if (params?.limit != null) qs.set('limit', String(params.limit))
if (params?.offset != null) qs.set('offset', String(params.offset))
const suffix = qs.toString() ? `?${qs.toString()}` : ''
const env = await mcpFetch<Envelope<{ items: HeicodeTask[]; total: number; offset: number; limit: number }>>(
`/api/user/tasks${suffix}`
)
const env = await mcpFetch<
Envelope<{
items: HeicodeTask[]
total: number
offset: number
limit: number
}>
>(`/api/user/tasks${suffix}`)
return env.data ?? { items: [], total: 0, offset: 0, limit: 0 }
}
export async function getHeicodeTask(id: string): Promise<HeicodeTask | null> {
try {
const env = await mcpFetch<Envelope<HeicodeTask>>(`/api/user/tasks/${encodeURIComponent(id)}`)
const env = await mcpFetch<Envelope<HeicodeTask>>(
`/api/user/tasks/${encodeURIComponent(id)}`
)
return env.data ?? null
} catch (e) {
if (e instanceof Error && /NOT_FOUND|404/i.test(e.message)) return null
@@ -191,6 +222,34 @@ export async function answerHeicodeTask(
return env.data
}
export async function createDeploymentDraftFromHeicodeTask(
task: HeicodeTask,
options: HeicodeTaskDeploymentDraftOptions = {}
): Promise<HeicodeTaskDeploymentDraft> {
const res = await api.post<Envelope<HeicodeTaskDeploymentDraft>>(
`/api/agnet/user/tasks/${encodeURIComponent(task.id)}/deployment-draft`,
{
task,
sub_mode: options.sub_mode ?? 'agile',
binding_scope: options.binding_scope ?? `task-${task.id}`,
role_templates: options.role_templates ?? ['backend'],
default_model_id: options.default_model_id ?? 'agnet-model-builder',
budget: options.budget ?? {
max_tokens: 120000,
max_cost_usd: 8,
max_duration_sec: 3600,
},
}
)
const env = res.data
if (!env?.success || !env.data) {
throw new Error(
env?.message || 'createDeploymentDraftFromHeicodeTask failed'
)
}
return env.data
}
// =============================================================================
// §4 NewAPI metadata passthrough — 4 endpoints
// All return Envelope<…>.
@@ -210,10 +269,15 @@ export type HeicodeBalance = {
export async function getHeicodeBalance(): Promise<HeicodeBalance | null> {
try {
const env = await mcpFetch<Envelope<HeicodeBalance>>('/api/user/heicode/balance')
const env = await mcpFetch<Envelope<HeicodeBalance>>(
'/api/user/heicode/balance'
)
return env.data ?? null
} catch (e) {
if (e instanceof Error && /NOT_CONFIGURED|HEICODE_USER_NOT_FOUND/i.test(e.message)) {
if (
e instanceof Error &&
/NOT_CONFIGURED|HEICODE_USER_NOT_FOUND/i.test(e.message)
) {
return null
}
throw e
@@ -227,34 +291,50 @@ export async function getHeicodeModels(): Promise<{
count: number
} | null> {
try {
const env = await mcpFetch<Envelope<{
const env = await mcpFetch<
Envelope<{
heicodeUserId: number
email: string
items: Array<Record<string, unknown>>
count: number
}>>('/api/user/heicode/models')
}>
>('/api/user/heicode/models')
return env.data ?? null
} catch (e) {
if (e instanceof Error && /NOT_CONFIGURED|HEICODE_USER_NOT_FOUND/i.test(e.message)) {
if (
e instanceof Error &&
/NOT_CONFIGURED|HEICODE_USER_NOT_FOUND/i.test(e.message)
) {
return null
}
throw e
}
}
export async function getHeicodeUsage(days = 30): Promise<Array<Record<string, unknown>>> {
const env = await mcpFetch<Envelope<{
export async function getHeicodeUsage(
days = 30
): Promise<Array<Record<string, unknown>>> {
const env = await mcpFetch<
Envelope<{
items: Array<Record<string, unknown>>
count: number
}>>(`/api/user/heicode/usage?days=${Math.max(1, Math.min(90, days))}`)
}>
>(`/api/user/heicode/usage?days=${Math.max(1, Math.min(90, days))}`)
return env.data?.items ?? []
}
export async function getHeicodeLogs(limit = 50, page = 1): Promise<Array<Record<string, unknown>>> {
const env = await mcpFetch<Envelope<{
export async function getHeicodeLogs(
limit = 50,
page = 1
): Promise<Array<Record<string, unknown>>> {
const env = await mcpFetch<
Envelope<{
items: Array<Record<string, unknown>>
count: number
}>>(`/api/user/heicode/logs?limit=${Math.max(1, Math.min(200, limit))}&page=${Math.max(1, page)}`)
}>
>(
`/api/user/heicode/logs?limit=${Math.max(1, Math.min(200, limit))}&page=${Math.max(1, page)}`
)
return env.data?.items ?? []
}
@@ -291,9 +371,9 @@ export async function listMcpAgnetDeployments(params?: {
if (params?.binding_scope) qs.set('binding_scope', params.binding_scope)
const suffix = qs.toString() ? `?${qs.toString()}` : ''
try {
const env = await mcpFetch<Envelope<{ items?: McpAgnetDeployment[]; total?: number }>>(
`/api/agnet/deployments${suffix}`
)
const env = await mcpFetch<
Envelope<{ items?: McpAgnetDeployment[]; total?: number }>
>(`/api/agnet/deployments${suffix}`)
return env.data?.items ?? []
} catch {
return []
@@ -363,22 +443,34 @@ export async function listResources(params?: {
status?: ResourceStatus
limit?: number
offset?: number
}): Promise<{ items: ResourceBinding[]; total: number; offset: number; limit: number }> {
}): Promise<{
items: ResourceBinding[]
total: number
offset: number
limit: number
}> {
const qs = new URLSearchParams()
if (params?.type) qs.set('type', params.type)
if (params?.status) qs.set('status', params.status)
if (params?.limit != null) qs.set('limit', String(params.limit))
if (params?.offset != null) qs.set('offset', String(params.offset))
const suffix = qs.toString() ? `?${qs.toString()}` : ''
const env = await mcpFetch<Envelope<{ items: ResourceBinding[]; total: number; offset: number; limit: number }>>(
`/api/resources${suffix}`
)
const env = await mcpFetch<
Envelope<{
items: ResourceBinding[]
total: number
offset: number
limit: number
}>
>(`/api/resources${suffix}`)
return env.data ?? { items: [], total: 0, offset: 0, limit: 100 }
}
export async function getResource(id: string): Promise<ResourceBinding | null> {
try {
const env = await mcpFetch<Envelope<ResourceBinding>>(`/api/resources/${encodeURIComponent(id)}`)
const env = await mcpFetch<Envelope<ResourceBinding>>(
`/api/resources/${encodeURIComponent(id)}`
)
return env.data ?? null
} catch (e) {
if (e instanceof Error && /404|NOT_FOUND/i.test(e.message)) return null
@@ -386,7 +478,9 @@ export async function getResource(id: string): Promise<ResourceBinding | null> {
}
}
export async function createResource(body: CreateResourceBody): Promise<ResourceBinding> {
export async function createResource(
body: CreateResourceBody
): Promise<ResourceBinding> {
const env = await mcpFetch<Envelope<ResourceBinding>>('/api/resources', {
method: 'POST',
body: JSON.stringify(body),
@@ -465,22 +559,36 @@ export async function listResourceGrants(params?: {
status?: GrantStatus
limit?: number
offset?: number
}): Promise<{ items: ResourceGrant[]; total: number; offset: number; limit: number }> {
}): Promise<{
items: ResourceGrant[]
total: number
offset: number
limit: number
}> {
const qs = new URLSearchParams()
if (params?.resource_id) qs.set('resource_id', params.resource_id)
if (params?.status) qs.set('status', params.status)
if (params?.limit != null) qs.set('limit', String(params.limit))
if (params?.offset != null) qs.set('offset', String(params.offset))
const suffix = qs.toString() ? `?${qs.toString()}` : ''
const env = await mcpFetch<Envelope<{ items: ResourceGrant[]; total: number; offset: number; limit: number }>>(
`/api/resource-grants${suffix}`
)
const env = await mcpFetch<
Envelope<{
items: ResourceGrant[]
total: number
offset: number
limit: number
}>
>(`/api/resource-grants${suffix}`)
return env.data ?? { items: [], total: 0, offset: 0, limit: 100 }
}
export async function getResourceGrant(id: string): Promise<ResourceGrant | null> {
export async function getResourceGrant(
id: string
): Promise<ResourceGrant | null> {
try {
const env = await mcpFetch<Envelope<ResourceGrant>>(`/api/resource-grants/${encodeURIComponent(id)}`)
const env = await mcpFetch<Envelope<ResourceGrant>>(
`/api/resource-grants/${encodeURIComponent(id)}`
)
return env.data ?? null
} catch (e) {
if (e instanceof Error && /404|NOT_FOUND/i.test(e.message)) return null
@@ -488,7 +596,9 @@ export async function getResourceGrant(id: string): Promise<ResourceGrant | null
}
}
export async function createResourceGrant(body: CreateGrantBody): Promise<ResourceGrant> {
export async function createResourceGrant(
body: CreateGrantBody
): Promise<ResourceGrant> {
const env = await mcpFetch<Envelope<ResourceGrant>>('/api/resource-grants', {
method: 'POST',
body: JSON.stringify(body),
@@ -527,9 +637,9 @@ export async function listMcpAuditLogs(params?: {
if (params?.limit != null) qs.set('limit', String(params.limit))
const suffix = qs.toString() ? `?${qs.toString()}` : ''
try {
const env = await mcpFetch<Envelope<{ items?: McpAuditEntry[]; total?: number }>>(
`/api/agnet/audit-logs${suffix}`
)
const env = await mcpFetch<
Envelope<{ items?: McpAuditEntry[]; total?: number }>
>(`/api/agnet/audit-logs${suffix}`)
return env.data?.items ?? []
} catch {
return []