diff --git a/heicode/controller/agnet_control_plane.go b/heicode/controller/agnet_control_plane.go index f59c271..e6bce7d 100644 --- a/heicode/controller/agnet_control_plane.go +++ b/heicode/controller/agnet_control_plane.go @@ -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 - applyAuthenticatedManagerUserContext(c, &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 == "" { diff --git a/heicode/controller/agnet_control_plane_test.go b/heicode/controller/agnet_control_plane_test.go index bd58cdf..c7f4be5 100644 --- a/heicode/controller/agnet_control_plane_test.go +++ b/heicode/controller/agnet_control_plane_test.go @@ -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) diff --git a/heicode/controller/agnet_task_bridge.go b/heicode/controller/agnet_task_bridge.go new file mode 100644 index 0000000..d94f1c8 --- /dev/null +++ b/heicode/controller/agnet_task_bridge.go @@ -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, + }) +} diff --git a/heicode/model/agnet_deployment.go b/heicode/model/agnet_deployment.go index 673cf19..870d306 100644 --- a/heicode/model/agnet_deployment.go +++ b/heicode/model/agnet_deployment.go @@ -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"` diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index 489b7df..8637cf6 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -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) } } } diff --git a/heicode/web/default/src/features/agnet-console/api.ts b/heicode/web/default/src/features/agnet-console/api.ts index 82bf5ec..a67579b 100644 --- a/heicode/web/default/src/features/agnet-console/api.ts +++ b/heicode/web/default/src/features/agnet-console/api.ts @@ -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 { export async function listAgnetDeployments(): Promise { const res = await api.get>( - '/api/agnet/deployments' + '/api/agnet/user/deployments' ) return res.data?.data?.items ?? [] } export async function listAgnetDeploymentsQuiet(): Promise { const res = await api.get>( - '/api/agnet/deployments', + '/api/agnet/user/deployments', { skipBusinessError: true, skipErrorHandler: true, @@ -297,7 +303,7 @@ export async function createAgnetDeployment( body: AgnetCreateDeploymentBody ): Promise { const res = await api.post>( - '/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> }> - >(`/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> }> diff --git a/heicode/web/default/src/features/agnet-console/create-agnet-deployment-sheet.tsx b/heicode/web/default/src/features/agnet-console/create-agnet-deployment-sheet.tsx index d9a6ce5..172b6cd 100644 --- a/heicode/web/default/src/features/agnet-console/create-agnet-deployment-sheet.tsx +++ b/heicode/web/default/src/features/agnet-console/create-agnet-deployment-sheet.tsx @@ -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> + roles: Array< + Pick + > } const RESOURCE_TYPES: ResourceType[] = [ @@ -338,10 +341,10 @@ function SectionTitle({ }) { return (
- +

{title}

- {hint ?

{hint}

: null} + {hint ?

{hint}

: null}
) @@ -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('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: '', 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({

{template.label}

{templateHint === template.id ? ( - + ) : null}
-

+

{template.objective}

@@ -751,7 +759,7 @@ export function CreateAgnetDeploymentSheet({ placeholder={t('Describe what this work should achieve.')} /> -
+
+
+ + +
@@ -905,7 +928,7 @@ export function CreateAgnetDeploymentSheet({ ) }} > - + ) : null} @@ -979,8 +1002,8 @@ export function CreateAgnetDeploymentSheet({ className='text-sm' /> -
-
+
+
{t('Resource grant')}
@@ -1099,8 +1122,8 @@ export function CreateAgnetDeploymentSheet({
-
-
+
+
{t('Runtime binding')}
@@ -1128,9 +1151,9 @@ export function CreateAgnetDeploymentSheet({
-
+
-
+
{t('SK access policy')}
@@ -1209,14 +1232,16 @@ export function CreateAgnetDeploymentSheet({ always matches what gets submitted. */}
{/* 1. 本次会做 — Objective + role list */} -
-

+

+

{t('This run will do')}

-

+

{objective.trim() || ( - {t('No objective provided yet — go back to the Idea tab.')} + {t( + 'No objective provided yet — go back to the Idea tab.' + )} )}

@@ -1225,7 +1250,7 @@ export function CreateAgnetDeploymentSheet({ {previewPlan.agent_runtime.agents.map((a) => ( {a.role} · @@ -1237,13 +1262,16 @@ export function CreateAgnetDeploymentSheet({
{/* 2. 本次允许使用 — Resources from permission_manifest */} -
-

+

+

{t('Resources this run may use')}

- {previewPlan.permission_manifest.resource_grants.length === 0 ? ( -

- {t('No resources bound yet — Agnet will run with no external data access.')} + {previewPlan.permission_manifest.resource_grants.length === + 0 ? ( +

+ {t( + 'No resources bound yet — Agnet will run with no external data access.' + )}

) : (
    @@ -1256,12 +1284,13 @@ export function CreateAgnetDeploymentSheet({ } className='flex items-center gap-2' > - - {(g as { resource_type?: string }).resource_type || - '?'} + + {(g as { resource_type?: string }) + .resource_type || '?'} - - {(g as { resource_id?: string }).resource_id || '—'} + + {(g as { resource_id?: string }).resource_id || + '—'} · @@ -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')} @@ -1284,10 +1317,10 @@ export function CreateAgnetDeploymentSheet({ {/* 3. 本次不会做 — Static off-limits list per §10 */}
    -

    +

    {t('This run will NOT do')}

    -
      +
      • · {t('Production deploys without client approval')}
      • · {t('Production database writes')}
      • · {t('Long-lived credential extraction')}
      • @@ -1296,11 +1329,11 @@ export function CreateAgnetDeploymentSheet({
    {/* 4. 高危规则 — Risk level + escalation policy */} -
    -

    +

    +

    {t('High-risk operations')}

    -

    +

    {t('Risk level')}: {riskLevel} + + sub_mode: {subMode} +

    -

    +

    {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({

    {/* 5. 预计消耗 — Budget triple */} -
    -

    +

    +

    {t('Expected consumption')}

    {t('Max tokens')}

    -

    +

    {maxTokens.toLocaleString()}

    {t('Max cost')}

    -

    +

    ${maxCost}

    @@ -1343,7 +1379,7 @@ export function CreateAgnetDeploymentSheet({

    {t('Max duration')}

    -

    +

    {Math.round(maxDurationSec / 60)} {t('min')}

    @@ -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. */} -
    - +
    + {t('Show raw payload (advanced)')} -
    +                
                       {JSON.stringify(previewPlan, null, 2)}
                     
    diff --git a/heicode/web/default/src/features/agnet-console/pages.tsx b/heicode/web/default/src/features/agnet-console/pages.tsx index 9798d48..d93c581 100644 --- a/heicode/web/default/src/features/agnet-console/pages.tsx +++ b/heicode/web/default/src/features/agnet-console/pages.tsx @@ -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')}

    - +
    + + +
    -
    +
    + void }) { return ( -
    +

    @@ -1255,7 +1294,7 @@ function AgnetLeaseCard({ onRevoke: () => void }) { return ( -

    +

    @@ -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() { )}

    -
    +
    -

    {t('Pending approvals')}

    +

    + {t('Pending approvals')} +

    - {t('Approve or reject high-risk Agnet operations before credentials are leased.')} + {t( + 'Approve or reject high-risk Agnet operations before credentials are leased.' + )}

    @@ -1429,12 +1474,16 @@ export function AgnetAuditPage() {
    -
    +
    -

    {t('Active credential leases')}

    +

    + {t('Active credential leases')} +

    - {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.' + )}

    @@ -1690,9 +1739,9 @@ export function AgnetSKSourcesPage() { ? t('{{n}} cloud account connected', { n: managerCloudAccounts.length, }) - : t( - 'Authorize Azure (AWS / GCP coming soon). Heicode auto-discovers cloud resources.' - ), + : t( + 'Authorize Azure (AWS / GCP coming soon). Heicode auto-discovers cloud resources.' + ), done: cloudSources.length > 0, }, { diff --git a/heicode/web/default/src/features/tasks/task-card-view.tsx b/heicode/web/default/src/features/tasks/task-card-view.tsx index 3c614f9..f2bb6a0 100644 --- a/heicode/web/default/src/features/tasks/task-card-view.tsx +++ b/heicode/web/default/src/features/tasks/task-card-view.tsx @@ -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 ( @@ -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)[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,11 +115,12 @@ function readManagerActions(card: HeicodeTask['card']): HeicodeManagerAction[] { const v = (card as Record).manager_actions if (!Array.isArray(v)) return [] return v - .filter((x): x is HeicodeManagerAction => - typeof x === 'object' && - x != null && - typeof (x as Record).label === 'string' && - typeof (x as Record).deeplink === 'string' + .filter( + (x): x is HeicodeManagerAction => + typeof x === 'object' && + x != null && + typeof (x as Record).label === 'string' && + typeof (x as Record).deeplink === 'string' ) .slice(0, 6) } @@ -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() {
    -

    - {t('Task not found. It may have been removed or was never created.')} +

    + {t( + 'Task not found. It may have been removed or was never created.' + )}

    @@ -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).goal as string | undefined) || + (task.card && + ((task.card as Record).goal as string | undefined)) || task.name || task.intent || t('No objective') @@ -246,7 +302,7 @@ export function TaskCardView() { {t('Back to home')} - + {task.id} @@ -269,11 +325,14 @@ export function TaskCardView() {
    -

    +

    {t('Heicode is asking')}

    -

    - {task.status_caption || t('Answer a few questions so Heicode can draft the right plan.')} +

    + {task.status_caption || + t( + 'Answer a few questions so Heicode can draft the right plan.' + )}

    @@ -282,7 +341,7 @@ export function TaskCardView() { {openFollowups.map((q) => (
  • {q.question}

    @@ -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' && ( - + {t('high-risk')} )} @@ -326,44 +387,46 @@ export function TaskCardView() { >
    -

    +

    {t('Task card')}

    {objective}

    {task.intent && task.intent !== objective && ( -

    {task.intent}

    +

    + {task.intent} +

    )}
    -
    +

    - + {t('First-version scope')}

    -
      +
        {firstVersionScope.map((line, i) => (
      • - + {line}
      • ))}
    -
    +

    - + {t('Heicode auto-generates')}

    -

    +

    {autoGenerated.join(' / ')}

    -

    +

    {t( 'These artifacts appear inside the desktop client as the task progresses.' )} @@ -371,8 +434,8 @@ export function TaskCardView() {

    -
    -

    +

    +

    {t('Needs Manager assistance')}

    @@ -397,19 +460,34 @@ export function TaskCardView() { }) ) : ( <> - - - +
    ) diff --git a/heicode/web/default/src/lib/heicode-mcp.ts b/heicode/web/default/src/lib/heicode-mcp.ts index fbbe250..e8c843d 100644 --- a/heicode/web/default/src/lib/heicode-mcp.ts +++ b/heicode/web/default/src/lib/heicode-mcp.ts @@ -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( - path: string, - init: RequestInit = {} -): Promise { +async function mcpFetch(path: string, init: RequestInit = {}): Promise { 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( 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 @@ -113,10 +112,10 @@ export type HeicodeManagerAction = { deeplink: string } export type HeicodeTaskCard = { - goal?: string // 任务目标(docs §10 "目标") - scope?: string[] // 第一版范围 - generated_artifacts?: string[] // Heicode 自动生成 - manager_actions?: HeicodeManagerAction[] // Manager 辅助按钮 + goal?: string // 任务目标(docs §10 "目标") + scope?: string[] // 第一版范围 + generated_artifacts?: string[] // Heicode 自动生成 + manager_actions?: HeicodeManagerAction[] // Manager 辅助按钮 // forward-compat passthrough [key: string]: unknown } | null @@ -136,7 +135,27 @@ export type HeicodeTask = { type Envelope = { success: boolean; data?: T; message?: string } -export async function createTaskFromIntent(intent: string, name?: string): Promise { +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 +} + +export async function createTaskFromIntent( + intent: string, + name?: string +): Promise { const env = await mcpFetch>('/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>( - `/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 { try { - const env = await mcpFetch>(`/api/user/tasks/${encodeURIComponent(id)}`) + const env = await mcpFetch>( + `/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 { + const res = await api.post>( + `/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 { try { - const env = await mcpFetch>('/api/user/heicode/balance') + const env = await mcpFetch>( + '/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> - count: number - }>>('/api/user/heicode/models') + const env = await mcpFetch< + Envelope<{ + heicodeUserId: number + email: string + items: Array> + count: number + }> + >('/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>> { - const env = await mcpFetch> - count: number - }>>(`/api/user/heicode/usage?days=${Math.max(1, Math.min(90, days))}`) +export async function getHeicodeUsage( + days = 30 +): Promise>> { + const env = await mcpFetch< + Envelope<{ + items: Array> + count: number + }> + >(`/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>> { - const env = await mcpFetch> - count: number - }>>(`/api/user/heicode/logs?limit=${Math.max(1, Math.min(200, limit))}&page=${Math.max(1, page)}`) +export async function getHeicodeLogs( + limit = 50, + page = 1 +): Promise>> { + const env = await mcpFetch< + Envelope<{ + items: Array> + count: number + }> + >( + `/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>( - `/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>( - `/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 { try { - const env = await mcpFetch>(`/api/resources/${encodeURIComponent(id)}`) + const env = await mcpFetch>( + `/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 { } } -export async function createResource(body: CreateResourceBody): Promise { +export async function createResource( + body: CreateResourceBody +): Promise { const env = await mcpFetch>('/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>( - `/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 { +export async function getResourceGrant( + id: string +): Promise { try { - const env = await mcpFetch>(`/api/resource-grants/${encodeURIComponent(id)}`) + const env = await mcpFetch>( + `/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 { +export async function createResourceGrant( + body: CreateGrantBody +): Promise { const env = await mcpFetch>('/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>( - `/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 []